Browse Source

Almost rully working Paypal, BTC, and LTC payment system with a fair price model.

master
Zed A. Shaw 2 months ago
parent
commit
c7e22e2ca1
  1. 3
      .gitignore
  2. 99
      api/payments/btcpay.js
  3. 39
      api/payments/paypal.js
  4. 128
      client/components/BTCPay.svelte
  5. 157
      client/components/Paypal.svelte
  6. 19
      client/config.js
  7. 9
      client/lib/helpers.js
  8. 150
      client/pages/Purchase.svelte
  9. 2
      client/routes.js
  10. 25
      lib/models.js
  11. 2
      lib/ormish.js
  12. 5
      package-lock.json
  13. 1
      package.json
  14. 1
      static/images/Bitcoin_logo.svg
  15. 1
      static/images/PayPal.svg

3
.gitignore

@ -9,3 +9,6 @@
debug/
static/thumbs
static/videos
secrets
client/lib/payments.js
lib/config.js

99
api/payments/btcpay.js

@ -0,0 +1,99 @@
import { Payment } from '../../lib/models.js';
import assert from 'assert';
import { log } from '../../lib/logging.js';
import { API } from "../../lib/api.js";
import fetch from 'node-fetch';
import { btcpay_private } from '../../secrets/payments.js';
import { product } from '../../client/config.js';
export const get = async (req, res) => {
const api = new API(req, res);
try{
const amount = parseInt(req.query.amount || product.price);
assert(amount >= 0 && amount <= 100, `invalid amount ${amount}`);
let internal_id = Payment.gen_internal_id();
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': btcpay_private.auth
},
body: JSON.stringify({
"price": amount,
"currency": product.currency,
"orderId": internal_id,
"itemDesc": product.description,
}),
}
const response = await fetch(btcpay_private.url + '/invoices', options);
const text = await response.text();
if(response.status != 200) {
log.info(`BTCPAY error: ${response.status}, text: '${text}'`);
api.error(403, 'BTCPay server failure');
} else {
const data = JSON.parse(text);
const sys_primary_id = data.data.id;
let payment = await Payment.insert({
system: 'btcpay',
status: 'pending',
internal_id,
sys_primary_id,
sys_secondary_id: data.data.guid,
sys_created_on: new Date(data.data.invoiceTime)
});
assert(payment, "Failed to store payment in the database. Email help@learnjsthehardway.com.");
api.reply(200, {sys_primary_id, internal_id});
}
} catch(error) {
log.error(error);
api.error(403, 'BTCPay server failure');
}
}
export const post = async (req, res) => {
const api = new API(req, res);
const msg = req.body;
try{
log.debug(`btcpay POST Recieved message ${JSON.stringify(msg)}`);
const options = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': btcpay_private.auth
},
}
const response = await fetch(btcpay_private.url + `/invoices/${msg.sys_primary_id}`, options);
const data = await response.json();
// TODO: what are the error conditions on this response?
const sys_primary_id = data.data.id;
const status = data.data.status;
const btc_due = data.data.btcDue;
if(status == 'paid') {
let count = await Payment.update({
sys_primary_id,
internal_id: msg.internal_id
}, {status: 'complete'});
assert(count === 1, "Failed to update payment database.");
}
api.reply(200, {sys_primary_id, status, btc_due});
} catch(error) {
log.error(error);
api.error(403, 'Failed to confirm payment');
}
}

39
api/payments/paypal.js

@ -0,0 +1,39 @@
import { Payment, User } from '../../lib/models.js';
import assert from 'assert';
import { API } from "../../lib/api.js";
import { log } from '../../lib/logging.js';
import dayjs from 'dayjs';
export const post = async (req, res) => {
const api = new API(req, res);
try{
const { msg } = req.body;
log.debug("Paypal recieved message", msg);
let internal_id = Payment.gen_internal_id();
let payment = await Payment.insert({
system: 'paypal',
status: 'complete',
internal_id,
sys_primary_id: msg.sys_primary_id,
sys_secondary_id: msg.sys_secondary_id,
sys_created_on: dayjs(msg.create_time)
});
assert(payment && payment.internal_id, "Failed to save payment.");
let result = {
sys_primary_id: payment.sys_primary_id,
status: payment.status,
internal_id: payment.internal_id
}
log.debug(result);
api.reply(200, result);
} catch(error) {
log.error(error);
app.error(403, 'PayPal configuration failed');
}
}

128
client/components/BTCPay.svelte

@ -0,0 +1,128 @@
<svelte:head>
<link rel="dns-prefetch" href="{ btcpay_url }">
<link rel="preconnect" href="{ btcpay_url }">
</svelte:head>
<script>
/**
* The contract with RegisterPanel.svelte is that this component will return:
* internal_id, sys_primary_id, status for use with the register/index.json.js handler.
*/
import { onMount } from 'svelte';
import { inject_remote } from '../lib/helpers.js';
let loading_btc = false;
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import Icon from './Icon.svelte';
import Spinner from './Spinner.svelte';
import { fake_payments, product, btcpay_url } from '../config.js';
export let amount = product.price;
let sys_primary_id;
let internal_id;
const btcpay_finished = (what) => dispatch('finished', {});
const btcpay_enter = (what) => {
loading_btc = false;
dispatch('loading', {});
}
const btcpay_leave = async (what) => {
loading_btc = false;
const resp = await fetch('/api/payments/btcpay', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ sys_primary_id, internal_id })
});
if(resp.status !== 200) {
dispatch("error", resp.text());
} else {
const invoice = await resp.json();
// TODO: confirm returned invoice ids match the requested ones
console.log('BTCPay invoice data', invoice);
let payment = {
system: 'btcpay',
internal_id,
sys_primary_id: sys_primary_id,
status: invoice.status === "paid" ? 'complete' : 'pending'
};
dispatch(payment.status === 'complete' ? 'finished' : 'canceled', payment);
}
}
const btcpay_submit = async () => {
if(fake_payments) {
// do a fake thing just to work on the UI
console.log("fake_payments set, sending finished event");
dispatch('finished', { system: 'btcpay', invoice_id: 'FAKE', sys_primary_id: 11234, status: 'complete'});
} else if(sys_primary_id !== undefined) {
// already have an invoice going so just open it again
loading_btc = true;
window.btcpay.showInvoice(sys_primary_id);
} else {
loading_btc = true;
// looks like this is fresh, get a new invoice to pay
const resp = await fetch(`/api/payments/btcpay?amount=${ amount }`);
if(resp.status !== 200) {
dispatch("error", resp.text());
} else {
const data = await resp.json();
// TODO: handle response errors from here
window.btcpay.onModalWillEnter(btcpay_enter);
window.btcpay.onModalWillLeave(btcpay_leave);
sys_primary_id = data.sys_primary_id;
internal_id = data.internal_id;
window.btcpay.showInvoice(sys_primary_id);
}
}
}
onMount(() => {
if(!window.btcpay){
inject_remote(document, btcpay_url);
}
});
</script>
<style lang="scss">
@import 'sass/_variables';
button.btcpay-submit {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
cursor: pointer;
width: 100%;
margin-bottom: 0.5rem;
background-color: var(--color-bg);
color: var(--color);
}
.bitcoin-logo {
max-height: 1rem;
color: #fff;
}
</style>
<button on:click|preventDefault={ btcpay_submit } data-testid="btcpay-submit" class="btcpay-submit" alt="Pay with ₿itcoin">
{#if loading_btc}
<Spinner />
{:else}
Pay with <img alt="Bitcoin" class="bitcoin-logo" src="/images/Bitcoin_logo.svg">
{/if}
</button>

157
client/components/Paypal.svelte

@ -0,0 +1,157 @@
<svelte:head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <!-- Ensures optimal rendering on mobile devices. -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <!-- Optimal Internet Explorer compatibility -->
</svelte:head>
<script>
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { paypal_public } from '../lib/payments.js';
import { inject_remote } from '../lib/helpers.js';
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { product } from '../config.js';
export let credit_card = false;
export let amount = product.amount;
let paypal_ready = false;
let paypal_sending = false;
let paypal_loaded = false;
const dispatch = createEventDispatcher();
const dispatch_finished = async (paypal) => {
paypal_sending = false;
if(paypal.status === "COMPLETED") {
let payment = {
system: 'paypal',
sys_primary_id: paypal.id,
sys_secondary_id: paypal.purchase_units[0].payments.captures[0].id,
sys_created_on: paypal.create_time,
status: 'complete',
};
const resp = await fetch('/api/payments/paypal', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify(payment)
});
const data = await resp.json();
console.log("Paypal request returned.");
// TODO: confirm no errors on reply and the returned data matches
payment.internal_id = data.internal_id;
dispatch('finished', payment);
} else {
alert("Impossible! Paypal status is not complete but dispatch finished?!");
}
}
const confirm_close = () => "Closing now will lose your purchase...please wait.";
const dispatch_canceled = (what) => dispatch('canceled', {paypal: what});
const dispatch_error = (what) => dispatch('error', {paypal: what});
const dispatch_loading = (what) => dispatch('loading', {});
const load_paypal = () => {
paypal_loaded = true;
dispatch('loading', {});
paypal.Buttons(
{
style: {
layout: 'vertical',
color: 'black',
label: 'pay',
tagline: false
},
onInit: (stuff) => paypal_ready = true,
createOrder: (data, actions) => {
return actions.order.create({
purchase_units: [{
amount: { value: `${ amount }` },
description: product.description,
}],
application_context: {
brand_name: product.description,
landing_page: 'BILLING',
shipping_preference: 'NO_SHIPPING',
user_action: 'PAY_NOW',
},
});
},
onApprove: (data, actions) => {
paypal_sending = true;
return actions.order.capture().then((details) => {
if(details.error === 'INSTRUMENT_DECLINED') {
// TODO: need to confirm this actually works
return actions.restart();
} else {
dispatch_finished(details);
}
});
},
onCancel: (data) => dispatch_canceled(data),
onError: (err) => dispatch_error(data),
}).render('#paypal-button-container');
}
const install_paypal = () => {
if(!window.paypal) {
let disable_funding = ['credit','bancontact',
'blik','eps','giropay','ideal','mybank',
'p24','sepa','sofort','venmo'];
if(!credit_card) disable_funding.push('card');
let script_url = `https://www.paypal.com/sdk/js?client-id=${paypal_public.client_id}&disable-funding=${disable_funding.join(',')}&debug=false&intent=capture&commit=true`;
inject_remote(document, script_url, load_paypal);
} else {
load_paypal();
}
}
</script>
<style type="text/css">
button.paypal-activate {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
cursor: pointer;
width: 100%;
margin-bottom: 0.5rem;
background-color: var(--color-bg);
color: var(--color);
}
.paypal-logo {
max-height: 1.2rem;
margin: 0.5rem;
vertical-align: bottom;
}
</style>
{#if paypal_sending}
<Modal testid="payment-confirm-modal" active={ true } ok_button={ false }>
Please wait while we confirm your payment...
</Modal>
{/if}
{#if !paypal_loaded}
<button on:click|preventDefault={ install_paypal } data-testid="paypal-activate" class="paypal-activate" alt="Pay with Paypal">
Pay with <img class="paypal-logo" src="/images/PayPal.svg" alt="Paypal" /> or &nbsp; <Icon name="credit-card" size="36px" />
</button>
{/if}
<div id="paypal-button-container">
</div>

19
client/config.js

@ -0,0 +1,19 @@
/* These are configuration options which can be public.
* WARNING: Do not put any sensitive keys in these files. They *will* be
* seen by the end user if you do. Only things an unauthenticated user
* is allowed to see. Anything else should go in lib/config.js and
* *never* import that file in any client/* files.
*/
export const fake_payments = false;
export const webtorrent = {
use_dht: false
}
export const product = {
price: 20,
currency: 'USD',
description: 'XOR Academy Subscription'
}
export const btcpay_url = 'https://pay.learnjsthehardway.com/modal/btcpay.js';

9
client/lib/helpers.js

@ -0,0 +1,9 @@
export function inject_remote(document, url, ready_cb=undefined) {
let head = document.getElementsByTagName('head')[0];
let script = document.createElement('script');
if(ready_cb) script.onload = ready_cb;
script.src=url;
script.type = 'text/javascript';
head.append(script);
}

150
client/pages/Purchase.svelte

@ -0,0 +1,150 @@
<script>
import { push, link } from 'svelte-spa-router';
import Validator from 'Validator';
import { onMount } from 'svelte';
import api from "../api.js";
import Form from "../components/Form.svelte";
import FormField from "../components/FormField.svelte";
import Icon from "../components/Icon.svelte";
import Paypal from "../components/Paypal.svelte";
import BTCPay from "../components/BTCPay.svelte";
const quips = {
"0": "Awww, really? Alright then.",
"10": "Fair enough.",
"20": "Not bad.",
"30": "Sweet!",
"40": "Yes! Love it!",
"50": "Booyah!",
"60": "Really?! Thank you!",
"70": "Whoa! No way!",
"80": "Fantastic! THANK YOU!",
"90": "NO WAY! YES!",
"100": "I love you!"
}
const form = {
amount: 20,
errors: {},
}
const rules = {
amount: "required",
}
api.mock({
"/api/purchase/": {
"get": [200, {"message": "OK"}],
}
});
const submit_purchase = async () => {
const [status, data] = await api.get("/api/purchase/");
if(status === 200) {
console.log("STATUS", status, "DATA", data);
} else {
console.error("Invalid response", status, data);
}
};
const change_amount = () => {
form.amount = form.amount < 100 ? form.amount + 10 : 0;
}
const payment_finished = (event) => {
console.log("FINISHED", event);
}
const payment_canceled = (event) => {
console.log("CANCELED", event);
}
const payment_error = (event) => {
console.log("ERROR", event);
}
const payment_loading = (event) => {
console.log("LOADING", event);
}
</script>
<style>
form#purchase {
width: min-content;
}
label.slider {
cursor: pointer;
display: flex;
height: 4rem;
width: 100%;
background-color: var(--color-bg-secondary);
user-select: none;
-webkit-user-select: none;
}
label.slider::after {
display: flex;
justify-content: center;
align-items: center;
width: calc(1% * var(--amount));
min-width: 2ch;
background-color: var(--color-bg-tertiary);
height: 4rem;
counter-reset: amount var(--amount);
content:'$' counter(amount);
transition: 0.5s;
}
input#amount {
display: none;
}
main {
display: flex;
justify-content: center;
align-items: start;
padding: 2rem;
}
</style>
<main>
<form method="POST" id="purchase">
<header>
<h1>What's a Fair Price?</h1>
{#if form.errors.main}
<error>{ form.errors.main }</error>
{/if}
</header>
<FormField form={ form } field="amount" label="{ quips[form.amount] }">
<label class="slider"
for="amount" style="--amount: { form.amount }"
on:click={ () => change_amount() }>
</label>
<input type="numeric" id="amount" bind:value={ form.amount } />
</FormField>
<p>Click on the slider to change <b>how much you think this course is worth</b>, then
<b>select your payment method</b>.</p>
{#if form.amount > 0}
<Paypal credit_card={ true } amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading } />
<BTCPay amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading } />
{:else}
<button>Pay Nothing</button>
{/if}
</form>
</main>

2
client/routes.js

@ -11,12 +11,14 @@ import Lesson from './pages/Lesson.svelte';
import Home from './pages/Home.svelte';
import NotFound from './pages/NotFound.svelte';
import Components from './bando/Components.svelte';
import Purchase from "./pages/Purchase.svelte";
export default {
'/register/': Register,
'/login/': Login,
'/forgot/': ResetPassword,
'/profile/': UserProfile,
'/purchase/': Purchase,
'/module/:module_id/': Module,
'/lesson/:lesson_id/': Lesson,
'/admin/table/create/:table/': AdminCreate,

25
lib/models.js

@ -1,6 +1,7 @@
import { knex, Model } from './ormish.js';
import bcrypt from 'bcryptjs';
import assert from 'assert';
import {v4 as uuid} from "uuid";
export class User extends Model.from_table('user') {
@ -84,3 +85,27 @@ export class Lesson extends Model.from_table("lesson") {
return await Module.first({id: this.module_id});
}
}
export class Payment extends Model.from_table('payment') {
user() {
return this.has_one(User, {id: this.user_id});
}
static fake_payment() {
return Payment.insert({
system: 'fake',
status: 'complete',
internal_id: Payment.gen_internal_id(),
sys_primary_id: Payment.gen_internal_id(),
sys_secondary_id: Payment.gen_internal_id(),
sys_created_on: dayjs()
});
}
static gen_internal_id() {
return uuid();
}
}

2
lib/ormish.js

@ -80,7 +80,7 @@ export class Model {
assert(this.table_name !== undefined, "You must set class variable table_name.");
assert(attr, `You must give some attr to insert into ${this.table_name}`);
let res = await knex(this.table_name).returning('id').insert(attr);
let res = await knex(this.table_name).insert(attr);
assert(res, `Failed to get an id from the insert for ${this.table_name}`);
attr.id = res[0];

5
package-lock.json

@ -5384,6 +5384,11 @@
"integrity": "sha1-GA6scAPgxwdhjvMTaPYvhLKmkJE=",
"dev": true
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
},
"node-gyp": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz",

1
package.json

@ -78,6 +78,7 @@
"knex-paginate": "^2.1.0",
"memorystore": "^1.6.6",
"morgan": "^1.10.0",
"node-fetch": "^2.6.1",
"nodemailer": "^6.5.0",
"nodemon": "^2.0.7",
"npm-watch": "^0.9.0",

1
static/images/Bitcoin_logo.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

1
static/images/PayPal.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="33"><path fill="#253B80" d="M46.211 6.749h-6.839a.95.95 0 0 0-.939.802l-2.766 17.537a.57.57 0 0 0 .564.658h3.265a.95.95 0 0 0 .939-.803l.746-4.73a.95.95 0 0 1 .938-.803h2.165c4.505 0 7.105-2.18 7.784-6.5.306-1.89.013-3.375-.872-4.415-.972-1.142-2.696-1.746-4.985-1.746zM47 13.154c-.374 2.454-2.249 2.454-4.062 2.454h-1.032l.724-4.583a.57.57 0 0 1 .563-.481h.473c1.235 0 2.4 0 3.002.704.359.42.469 1.044.332 1.906zM66.654 13.075h-3.275a.57.57 0 0 0-.563.481l-.145.916-.229-.332c-.709-1.029-2.29-1.373-3.868-1.373-3.619 0-6.71 2.741-7.312 6.586-.313 1.918.132 3.752 1.22 5.031.998 1.176 2.426 1.666 4.125 1.666 2.916 0 4.533-1.875 4.533-1.875l-.146.91a.57.57 0 0 0 .562.66h2.95a.95.95 0 0 0 .939-.803l1.77-11.209a.568.568 0 0 0-.561-.658zm-4.565 6.374c-.316 1.871-1.801 3.127-3.695 3.127-.951 0-1.711-.305-2.199-.883-.484-.574-.668-1.391-.514-2.301.295-1.855 1.805-3.152 3.67-3.152.93 0 1.686.309 2.184.892.499.589.697 1.411.554 2.317zM84.096 13.075h-3.291a.954.954 0 0 0-.787.417l-4.539 6.686-1.924-6.425a.953.953 0 0 0-.912-.678h-3.234a.57.57 0 0 0-.541.754l3.625 10.638-3.408 4.811a.57.57 0 0 0 .465.9h3.287a.949.949 0 0 0 .781-.408l10.946-15.8a.57.57 0 0 0-.468-.895z"/><path fill="#179BD7" d="M94.992 6.749h-6.84a.95.95 0 0 0-.938.802l-2.766 17.537a.569.569 0 0 0 .562.658h3.51a.665.665 0 0 0 .656-.562l.785-4.971a.95.95 0 0 1 .938-.803h2.164c4.506 0 7.105-2.18 7.785-6.5.307-1.89.012-3.375-.873-4.415-.971-1.142-2.694-1.746-4.983-1.746zm.789 6.405c-.373 2.454-2.248 2.454-4.062 2.454h-1.031l.725-4.583a.568.568 0 0 1 .562-.481h.473c1.234 0 2.4 0 3.002.704.359.42.468 1.044.331 1.906zM115.434 13.075h-3.273a.567.567 0 0 0-.562.481l-.145.916-.23-.332c-.709-1.029-2.289-1.373-3.867-1.373-3.619 0-6.709 2.741-7.311 6.586-.312 1.918.131 3.752 1.219 5.031 1 1.176 2.426 1.666 4.125 1.666 2.916 0 4.533-1.875 4.533-1.875l-.146.91a.57.57 0 0 0 .564.66h2.949a.95.95 0 0 0 .938-.803l1.771-11.209a.571.571 0 0 0-.565-.658zm-4.565 6.374c-.314 1.871-1.801 3.127-3.695 3.127-.949 0-1.711-.305-2.199-.883-.484-.574-.666-1.391-.514-2.301.297-1.855 1.805-3.152 3.67-3.152.93 0 1.686.309 2.184.892.501.589.699 1.411.554 2.317zM119.295 7.23l-2.807 17.858a.569.569 0 0 0 .562.658h2.822c.469 0 .867-.34.939-.803l2.768-17.536a.57.57 0 0 0-.562-.659h-3.16a.571.571 0 0 0-.562.482z"/><path fill="#253B80" d="M7.266 29.154l.523-3.322-1.165-.027H1.061L4.927 1.292a.316.316 0 0 1 .314-.268h9.38c3.114 0 5.263.648 6.385 1.927.526.6.861 1.227 1.023 1.917.17.724.173 1.589.007 2.644l-.012.077v.676l.526.298a3.69 3.69 0 0 1 1.065.812c.45.513.741 1.165.864 1.938.127.795.085 1.741-.123 2.812-.24 1.232-.628 2.305-1.152 3.183a6.547 6.547 0 0 1-1.825 2c-.696.494-1.523.869-2.458 1.109-.906.236-1.939.355-3.072.355h-.73c-.522 0-1.029.188-1.427.525a2.21 2.21 0 0 0-.744 1.328l-.055.299-.924 5.855-.042.215c-.011.068-.03.102-.058.125a.155.155 0 0 1-.096.035H7.266z"/><path fill="#179BD7" d="M23.048 7.667c-.028.179-.06.362-.096.55-1.237 6.351-5.469 8.545-10.874 8.545H9.326c-.661 0-1.218.48-1.321 1.132L6.596 26.83l-.399 2.533a.704.704 0 0 0 .695.814h4.881c.578 0 1.069-.42 1.16-.99l.048-.248.919-5.832.059-.32c.09-.572.582-.992 1.16-.992h.73c4.729 0 8.431-1.92 9.513-7.476.452-2.321.218-4.259-.978-5.622a4.667 4.667 0 0 0-1.336-1.03z"/><path fill="#222D65" d="M21.754 7.151a9.757 9.757 0 0 0-1.203-.267 15.284 15.284 0 0 0-2.426-.177h-7.352a1.172 1.172 0 0 0-1.159.992L8.05 17.605l-.045.289a1.336 1.336 0 0 1 1.321-1.132h2.752c5.405 0 9.637-2.195 10.874-8.545.037-.188.068-.371.096-.55a6.594 6.594 0 0 0-1.017-.429 9.045 9.045 0 0 0-.277-.087z"/><path fill="#253B80" d="M9.614 7.699a1.169 1.169 0 0 1 1.159-.991h7.352c.871 0 1.684.057 2.426.177a9.757 9.757 0 0 1 1.481.353c.365.121.704.264 1.017.429.368-2.347-.003-3.945-1.272-5.392C20.378.682 17.853 0 14.622 0h-9.38c-.66 0-1.223.48-1.325 1.133L.01 25.898a.806.806 0 0 0 .795.932h5.791l1.454-9.225 1.564-9.906z"/></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

Loading…
Cancel
Save