├── .nvmrc ├── test ├── pages │ └── .keep ├── server │ └── .keep ├── __fixtures__ │ ├── .keep │ └── payload_simple_cards.json ├── setup.js ├── __mocks__ │ └── fileMock.js ├── __helpers__ │ └── renderWithTheme.js └── components │ ├── PrintableGiftCard.test.js │ └── __snapshots__ │ └── PrintableGiftCard.test.js.snap ├── src ├── constants │ ├── collectives.js │ ├── env.js │ └── theme.js ├── static │ ├── fonts │ │ ├── Inter-UI-Black.woff │ │ ├── Inter-UI-Bold.woff │ │ ├── Inter-UI-Bold.woff2 │ │ ├── Inter-UI-Black.woff2 │ │ ├── Inter-UI-Italic.woff │ │ ├── Inter-UI-Italic.woff2 │ │ ├── Inter-UI-Medium.woff │ │ ├── Inter-UI-Medium.woff2 │ │ ├── Inter-UI-Regular.woff │ │ ├── Inter-UI-Regular.woff2 │ │ ├── Inter-UI-BoldItalic.woff │ │ ├── Inter-UI-BlackItalic.woff │ │ ├── Inter-UI-BlackItalic.woff2 │ │ ├── Inter-UI-BoldItalic.woff2 │ │ ├── Inter-UI-MediumItalic.woff │ │ └── Inter-UI-MediumItalic.woff2 │ └── images │ │ ├── oc-gift-card-front-straightened.png │ │ ├── oc-gift-card-front-straightened.svg │ │ └── opencollective-icon.svg ├── server │ ├── env.js │ ├── middlewares.js │ ├── index.js │ ├── lib │ │ └── utils.js │ ├── app.js │ ├── logger.js │ ├── routes.js │ └── controllers │ │ └── index.js ├── components │ ├── StyledKeyframes.js │ ├── StyledSpinner.js │ ├── Currency.js │ ├── StyledHr.js │ ├── Container.js │ ├── Text.js │ ├── StyledButton.js │ ├── StyledInput.js │ └── PrintableGiftCard.js ├── pages │ ├── _app.js │ ├── _document.js │ ├── gift-cards.js │ └── index.js └── lib │ ├── utils.js │ └── withIntl.js ├── .prettierrc ├── .gitignore ├── .eslintrc ├── now.json ├── next.config.js ├── styleguide ├── Wrapper.js └── examples │ └── PrintableGiftCard.md ├── .babelrc ├── Dockerfile ├── LICENSE ├── .circleci └── config.yml ├── styleguide.config.js ├── README.md ├── scripts └── pre-deploy.sh └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v11.8 2 | -------------------------------------------------------------------------------- /test/pages/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/server/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/__fixtures__/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import 'jest-styled-components'; 2 | -------------------------------------------------------------------------------- /test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = '[IMAGE_URL]'; 2 | -------------------------------------------------------------------------------- /src/constants/collectives.js: -------------------------------------------------------------------------------- 1 | export const OPENSOURCE_COLLECTIVE_ID = 11004; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Black.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Bold.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Bold.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Black.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Italic.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Italic.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Medium.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Medium.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-BoldItalic.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-BlackItalic.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-BlackItalic.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-BoldItalic.woff2 -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-MediumItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-MediumItalic.woff -------------------------------------------------------------------------------- /src/static/fonts/Inter-UI-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/fonts/Inter-UI-MediumItalic.woff2 -------------------------------------------------------------------------------- /src/server/env.js: -------------------------------------------------------------------------------- 1 | // Load environment variables 2 | import debug from 'debug'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | debug.enable(process.env.DEBUG); 7 | -------------------------------------------------------------------------------- /src/static/images/oc-gift-card-front-straightened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencollective/opencollective-giftcards-generator/HEAD/src/static/images/oc-gift-card-front-straightened.png -------------------------------------------------------------------------------- /src/server/middlewares.js: -------------------------------------------------------------------------------- 1 | export const maxAge = (maxAge = 60) => { 2 | return (req, res, next) => { 3 | res.setHeader('Cache-Control', `public, max-age=${maxAge}`); 4 | next(); 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import { logger } from './logger'; 3 | import { PORT } from '../constants/env'; 4 | 5 | app.listen(PORT, err => { 6 | if (err) { 7 | throw err; 8 | } 9 | 10 | logger.info(`Ready on http://localhost:${PORT}`); 11 | }); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .env 3 | node_modules 4 | npm-debug.log.* 5 | *.log 6 | yarn.lock 7 | .DS_Store 8 | build 9 | cypress/screenshots 10 | *.swp 11 | dist 12 | coverage 13 | styleguide/build 14 | styleguide/static 15 | styleguide/index.html 16 | 17 | # VSCode 18 | .vscode/* 19 | .history 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["opencollective"], 3 | "env": { 4 | "jest": true 5 | }, 6 | "plugins": ["react-hooks"], 7 | "rules": { 8 | "no-console": "warn", 9 | "react/jsx-closing-bracket-location": ["warn", "tag-aligned"], 10 | "react-hooks/rules-of-hooks": ["error"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/constants/env.js: -------------------------------------------------------------------------------- 1 | export const ENV = process.env.NODE_ENV || 'development'; 2 | export const PORT = process.env.PORT || 3004; 3 | export const WEBSITE_URL = process.env.WEBSITE_URL || 'http://localhost:3000'; 4 | export const GENERATOR_URL = process.env.GENERATOR_URL || `http://localhost:${PORT}`; 5 | export const STATIC_ASSETS_URL = process.env.STATIC_ASSETS_URL || `${GENERATOR_URL}/static`; 6 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "type": "docker", 4 | "name": "opencollective-giftcards-generator", 5 | "alias": "giftcards-generator.opencollective.com", 6 | "env": { 7 | "WEBSITE_URL": "https://opencollective.com" 8 | }, 9 | "scale": { 10 | "sfo1": { 11 | "min": 1, 12 | "max": 1 13 | } 14 | }, 15 | "github": { 16 | "autoAlias": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/__helpers__/renderWithTheme.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import theme from '../../src/constants/theme'; 5 | 6 | const renderWithTheme = component => { 7 | return renderer.create({component}); 8 | }; 9 | 10 | export default renderWithTheme; 11 | -------------------------------------------------------------------------------- /src/components/StyledKeyframes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A set of styled-components keyframes animations 3 | */ 4 | 5 | import { keyframes } from 'styled-components'; 6 | 7 | export const rotating = keyframes` 8 | from { 9 | transform: rotate(0deg); 10 | } 11 | to { 12 | transform: rotate(360deg); 13 | } 14 | `; 15 | 16 | export const fadeIn = keyframes` 17 | from { 18 | opacity: 0; 19 | } 20 | to { 21 | opacity: 1; 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line import/no-unresolved */ 2 | 3 | const nextConfig = { 4 | webpack: config => { 5 | config.module.rules.push({ 6 | test: /.*\.(jpg|gif|png|svg|)$/, 7 | use: { 8 | loader: 'url-loader', 9 | options: { 10 | limit: 1000000, 11 | fallback: 'file-loader', 12 | }, 13 | }, 14 | }); 15 | 16 | return config; 17 | }, 18 | }; 19 | 20 | module.exports = nextConfig; 21 | -------------------------------------------------------------------------------- /styleguide/Wrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | 4 | import theme from '../src/constants/theme'; 5 | import { IntlProvider } from 'react-intl'; 6 | 7 | export default class ThemeWrapper extends Component { 8 | render() { 9 | return ( 10 | 11 | 12 | {this.props.children} 13 | 14 | 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "next/babel", 5 | { 6 | "preset-env": { 7 | "modules": "auto" 8 | }, 9 | "transform-runtime": { 10 | "useESModules": false 11 | } 12 | } 13 | ] 14 | ], 15 | "plugins": [ 16 | "lodash", 17 | "babel-plugin-styled-components", 18 | [ 19 | "react-intl", 20 | { 21 | "messagesDir": "./dist/messages/" 22 | } 23 | ] 24 | ], 25 | "env": { 26 | "development": { 27 | "compact": false 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/lib/utils.js: -------------------------------------------------------------------------------- 1 | export const queryString = { 2 | stringify: obj => { 3 | let str = ''; 4 | for (const key in obj) { 5 | if (str != '') { 6 | str += '&'; 7 | } 8 | str += `${key}=${encodeURIComponent(obj[key])}`; 9 | } 10 | return str; 11 | }, 12 | parse: query => { 13 | if (!query) return {}; 14 | const vars = query.split('&'); 15 | const res = {}; 16 | for (let i = 0; i < vars.length; i++) { 17 | const pair = vars[i].split('='); 18 | res[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]); 19 | } 20 | return res; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App, { Container } from 'next/app'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import { IntlProvider } from 'react-intl'; 5 | 6 | import theme from '../constants/theme'; 7 | 8 | class OpenCollectiveFrontendApp extends App { 9 | render() { 10 | const { Component, pageProps } = this.props; 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | } 23 | 24 | export default OpenCollectiveFrontendApp; 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11.8 2 | 3 | WORKDIR /usr/src/frontend 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm ci 8 | 9 | COPY . . 10 | 11 | ARG PORT=3000 12 | ENV PORT $PORT 13 | 14 | ARG NODE_ENV=production 15 | ENV NODE_ENV $NODE_ENV 16 | 17 | ARG WEBSITE_URL=https://staging.opencollective.com 18 | ENV WEBSITE_URL $WEBSITE_URL 19 | 20 | # Copy fonts and update fonts cache 21 | RUN cp src/static/fonts/* /usr/share/fonts/ 22 | RUN fc-cache -f -v 23 | RUN fc-list 24 | 25 | # Show the phantom version, may be useful if we need to debug a production issue 26 | RUN ./node_modules/phantomjs-prebuilt/bin/phantomjs --version 27 | 28 | RUN npm run build 29 | 30 | EXPOSE ${PORT} 31 | 32 | CMD [ "npm", "run", "start" ] 33 | -------------------------------------------------------------------------------- /test/components/PrintableGiftCard.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { IntlProvider } from 'react-intl'; 4 | 5 | import PrintableGiftCard from '../../src/components/PrintableGiftCard'; 6 | 7 | it('renders correctly', () => { 8 | const tree = renderer 9 | .create( 10 | 11 | 20 | , 21 | ) 22 | .toJSON(); 23 | 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | -------------------------------------------------------------------------------- /styleguide/examples/PrintableGiftCard.md: -------------------------------------------------------------------------------- 1 | ## Default 2 | 3 | ```js 4 | 10 | ``` 11 | 12 | ## Customization 13 | 14 | ```js 15 |
16 | 25 |
26 | 34 |
35 | ``` 36 | -------------------------------------------------------------------------------- /src/components/StyledSpinner.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { LoaderAlt } from 'styled-icons/boxicons-regular/LoaderAlt'; 3 | import styled from 'styled-components'; 4 | import { rotating } from './StyledKeyframes'; 5 | 6 | /** A loading spinner using SVG + css animation. */ 7 | const StyledSpinner = styled(LoaderAlt)` 8 | animation: ${rotating} 1s linear infinite; 9 | `; 10 | 11 | StyledSpinner.defaultProps = { 12 | title: 'Loading', 13 | size: '1em', 14 | }; 15 | 16 | StyledSpinner.propTypes = { 17 | /** From styled-icons, this is a convenience for setting both width and height to the same value */ 18 | size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 19 | /** A title for accessibility */ 20 | title: PropTypes.string, 21 | }; 22 | 23 | /** @component */ 24 | export default StyledSpinner; 25 | -------------------------------------------------------------------------------- /src/server/app.js: -------------------------------------------------------------------------------- 1 | import './env'; 2 | 3 | import path from 'path'; 4 | import express from 'express'; 5 | import next from 'next'; 6 | import cors from 'cors'; 7 | 8 | import { loggerMiddleware } from './logger'; 9 | import routes from './routes'; 10 | import { ENV, WEBSITE_URL } from '../constants/env'; 11 | 12 | const isDev = ['development', 'docker', 'test'].includes(ENV); 13 | const server = express(); 14 | const nextApp = next({ dev: isDev, dir: path.dirname(__dirname) }); 15 | server.next = nextApp; 16 | 17 | nextApp.prepare().then(() => { 18 | // Configure loggers 19 | server.use(loggerMiddleware.logger); 20 | server.use(loggerMiddleware.errorLogger); 21 | 22 | // Set CORS for frontend 23 | server.use(cors({ origin: WEBSITE_URL })); 24 | 25 | // Configure routes 26 | server.use(express.json()); 27 | server.use(routes(server, nextApp)); 28 | }); 29 | 30 | export default server; 31 | -------------------------------------------------------------------------------- /src/components/Currency.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedNumber } from 'react-intl'; 3 | 4 | import { abbreviateNumber } from '../lib/utils'; 5 | 6 | import { Span } from './Text'; 7 | 8 | const Currency = ({ abbreviate = false, currency, precision = 0, value, ...styles }) => ( 9 | 17 | {formattedNumber => 18 | abbreviate ? ( 19 | 20 | {formattedNumber.slice(0, 1)} 21 | {abbreviateNumber(value / 100, precision)} 22 | 23 | ) : ( 24 | 25 | {formattedNumber} 26 | 27 | ) 28 | } 29 | 30 | ); 31 | 32 | export default Currency; 33 | -------------------------------------------------------------------------------- /src/pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document from 'next/document'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | 5 | /** 6 | * Document wrapper that includes styled-components. 7 | * See https://github.com/zeit/next.js/blob/master/examples/with-styled-components/pages/_document.js 8 | */ 9 | export default class MyDocument extends Document { 10 | static async getInitialProps(ctx) { 11 | const sheet = new ServerStyleSheet(); 12 | const originalRenderPage = ctx.renderPage; 13 | 14 | try { 15 | ctx.renderPage = () => 16 | originalRenderPage({ 17 | enhanceApp: App => props => sheet.collectStyles(), 18 | }); 19 | 20 | const initialProps = await Document.getInitialProps(ctx); 21 | return { 22 | ...initialProps, 23 | styles: ( 24 | <> 25 | {initialProps.styles} 26 | {sheet.getStyleElement()} 27 | 28 | ), 29 | }; 30 | } finally { 31 | sheet.seal(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2018 Open Collective, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/server/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import expressWinston from 'express-winston'; 3 | 4 | function getLogLevel() { 5 | if (process.env.LOG_LEVEL) { 6 | return process.env.LOG_LEVEL; 7 | } else if ( 8 | process.env.NODE_ENV === 'production' || 9 | process.env.NODE_ENV === 'test' || 10 | process.env.NODE_ENV === 'circleci' 11 | ) { 12 | return 'warn'; 13 | } else { 14 | return 'info'; 15 | } 16 | } 17 | 18 | const logger = winston.createLogger(); 19 | 20 | const winstonConsole = new winston.transports.Console({ 21 | level: getLogLevel(), 22 | format: winston.format.combine(winston.format.colorize(), winston.format.splat(), winston.format.simple()), 23 | }); 24 | 25 | logger.add(winstonConsole); 26 | logger.exceptions.handle(winstonConsole); 27 | 28 | const loggerMiddleware = { 29 | logger: expressWinston.logger({ 30 | winstonInstance: logger, 31 | meta: false, 32 | colorize: true, 33 | expressFormat: true, 34 | ignoreRoute: req => req.url.match(/^\/_/), 35 | }), 36 | errorLogger: expressWinston.errorLogger({ 37 | winstonInstance: logger, 38 | }), 39 | }; 40 | 41 | export { logger, loggerMiddleware }; 42 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:11.8.0 6 | environment: 7 | NODE_ENV: circleci 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | name: Restore NPM cache 12 | keys: 13 | - npm-cache-{{ checksum "package.json" }} 14 | - npm-cache- 15 | - run: 16 | name: Install dependencies 17 | command: npm ci 18 | - save_cache: 19 | name: Persist NPM cache 20 | key: npm-cache-{{ checksum "package.json" }} 21 | paths: 22 | - ../.npm 23 | - run: 24 | name: Create test results folders 25 | command: | 26 | mkdir /tmp/circleci-test-results 27 | mkdir /tmp/circleci-test-results/jest 28 | - run: 29 | name: Run Tests 30 | command: | 31 | npm run test -- --ci --coverage --reporters=default --reporters=jest-junit 32 | npx codecov 33 | environment: 34 | JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/results.xml 35 | - store_test_results: 36 | path: /tmp/circleci-test-results 37 | -------------------------------------------------------------------------------- /src/server/routes.js: -------------------------------------------------------------------------------- 1 | import nextRoutes from 'next-routes'; 2 | import { renderMany, testFixture } from './controllers'; 3 | import { maxAge } from './middlewares'; 4 | 5 | const router = nextRoutes(); 6 | 7 | export default (server, app) => { 8 | const bindApp = (req, res, next) => { 9 | req.app = app; 10 | next(); 11 | }; 12 | 13 | /** 14 | * By default, we cache all GET calls for 30s at the CDN level (cloudflare) 15 | * note: only for production/staging (NextJS overrides this in development env) 16 | */ 17 | server.get('*', maxAge(30)); 18 | 19 | /** 20 | * Cache static assets for a longer time 21 | */ 22 | server.get('/static/*', maxAge(7200)); 23 | 24 | /** 25 | * Prevent all indexation from search engines (this is a private service) 26 | */ 27 | server.get('/robots.txt', (req, res) => { 28 | res.setHeader('Content-Type', 'text/plain'); 29 | res.send('User-agent: *\nDisallow: /'); 30 | }); 31 | 32 | /** 33 | * Endpoint to download a pdf 34 | */ 35 | server.post('/render-many/:filename.:format(pdf|html)', bindApp, renderMany); 36 | 37 | server.get('/__test__/:filename.:format(pdf|html)', bindApp, testFixture); 38 | 39 | return router.getRequestHandler(server.next); 40 | }; 41 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { GENERATOR_URL, STATIC_ASSETS_URL } from '../constants/env'; 2 | 3 | const SI_PREFIXES = ['', 'k', 'M', 'G', 'T', 'P', 'E']; 4 | 5 | /* 6 | * Shortens a number to the abbreviated thousands, millions, billions, etc 7 | * https://stackoverflow.com/a/40724354 8 | 9 | * @param {number} number: value to shorten 10 | * @returns {string|number} 11 | 12 | * @example 13 | * // return '12.3k' 14 | * abbreviateNumber(12345, 1) 15 | */ 16 | export const abbreviateNumber = (number, precision = 0) => { 17 | // what tier? (determines SI prefix) 18 | const tier = (Math.log10(number) / 3) | 0; 19 | 20 | const round = value => { 21 | return precision === 0 ? Math.round(value) : value.toFixed(precision); 22 | }; 23 | 24 | // if zero, we don't need a prefix 25 | if (tier == 0) return round(number); 26 | 27 | // get prefix and determine scale 28 | const scale = Math.pow(10, tier * 3); 29 | 30 | // scale the number 31 | const scaled = number / scale; 32 | 33 | return round(scaled) + SI_PREFIXES[tier]; 34 | }; 35 | 36 | /** 37 | * Get image URL from relative path 38 | * 39 | * @param {string} relativePath - the path withtout `/static/images/` 40 | */ 41 | export const imgUrl = relativePath => { 42 | return `${STATIC_ASSETS_URL}/images/${relativePath}`; 43 | }; 44 | -------------------------------------------------------------------------------- /src/static/images/oc-gift-card-front-straightened.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/StyledHr.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import styled from 'styled-components'; 3 | import { themeGet, space, minWidth, maxWidth, boxShadow, borderColor } from 'styled-system'; 4 | import tag from 'clean-tag'; 5 | 6 | const StyledHr = styled(tag.hr)` 7 | border: 0; 8 | border-top: 1px solid ${themeGet('colors.black.400')}; 9 | margin: 0; 10 | height: 1px; 11 | 12 | ${space} 13 | ${minWidth} 14 | ${maxWidth} 15 | ${boxShadow} 16 | ${borderColor} 17 | `; 18 | 19 | StyledHr.propTypes = { 20 | /** styled-system prop: accepts any css 'border-color' value or theme color */ 21 | borderColor: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 22 | /** accepts any css 'border-style' value */ 23 | borderStyle: PropTypes.string, 24 | /** styled-system prop: accepts any css 'box-shadow' value */ 25 | boxShadow: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 26 | /** styled-system prop: accepts any css 'max-width' value */ 27 | maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 28 | /** styled-system prop: accepts any css 'min-width' value */ 29 | minWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 30 | /** 31 | * styled-system prop: adds margin & padding props 32 | * see: https://github.com/jxnblk/styled-system/blob/master/docs/api.md#space 33 | */ 34 | space: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 35 | }; 36 | 37 | StyledHr.defaultProps = { 38 | /** @ignore */ 39 | omitProps: tag.defaultProps.omitProps.concat('borderStyle'), 40 | }; 41 | 42 | /** @component */ 43 | export default StyledHr; 44 | -------------------------------------------------------------------------------- /src/static/images/opencollective-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Slice 5 | Created with Sketch. 6 | 7 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const fileExistsCaseInsensitive = require('react-styleguidist/lib/scripts/utils/findFileCaseInsensitive'); 4 | 5 | module.exports = { 6 | assetsDir: 'src/static', 7 | serverPort: 6061, 8 | getExampleFilename(componentPath) { 9 | const parsedPath = path.parse(componentPath); 10 | const parentDirName = parsedPath.dir.split('src/components/')[1] || ''; 11 | const parentDirPath = path.join(__dirname, 'styleguide', 'examples', parentDirName); 12 | 13 | if (!fs.existsSync(parentDirPath)) { 14 | return false; 15 | } 16 | 17 | const examplePath = path.join(parentDirPath, `${parsedPath.name}.md`); 18 | return fileExistsCaseInsensitive(examplePath) || false; 19 | }, 20 | pagePerSection: true, 21 | moduleAliases: { 22 | components: path.resolve(__dirname, 'src/components'), 23 | }, 24 | skipComponentsWithoutExample: true, 25 | styleguideComponents: { 26 | Wrapper: path.join(__dirname, 'styleguide/Wrapper'), 27 | }, 28 | title: 'Open Collective - Gift Cards Styleguide', 29 | usageMode: 'expand', 30 | webpackConfig: { 31 | resolve: { extensions: ['.js', '.json'] }, 32 | stats: { children: false, chunks: false, modules: false, reasons: false }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(js|jsx)$/, 37 | exclude: /node_modules/, 38 | use: [{ loader: 'babel-loader', options: { cacheDirectory: true } }], 39 | }, 40 | { 41 | test: /components\/.*\.(svg)$/, 42 | use: { 43 | loader: 'url-loader', 44 | options: { 45 | limit: 1000000, 46 | }, 47 | }, 48 | }, 49 | ], 50 | }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/Container.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import tag from 'clean-tag'; 3 | import { 4 | alignItems, 5 | alignSelf, 6 | background, 7 | backgroundImage, 8 | backgroundPosition, 9 | backgroundRepeat, 10 | backgroundSize, 11 | borders, 12 | borderColor, 13 | borderRadius, 14 | bottom, 15 | boxShadow, 16 | color, 17 | display, 18 | flex, 19 | flexDirection, 20 | flexWrap, 21 | fontSize, 22 | fontWeight, 23 | height, 24 | justifyContent, 25 | left, 26 | lineHeight, 27 | maxHeight, 28 | maxWidth, 29 | minHeight, 30 | minWidth, 31 | order, 32 | position, 33 | right, 34 | size, 35 | space, 36 | textAlign, 37 | top, 38 | width, 39 | zIndex, 40 | } from 'styled-system'; 41 | 42 | const Container = styled(tag)` 43 | box-sizing: border-box; 44 | 45 | ${alignItems} 46 | ${alignSelf} 47 | ${background} 48 | ${backgroundImage} 49 | ${backgroundPosition} 50 | ${backgroundRepeat} 51 | ${backgroundSize} 52 | ${borders} 53 | ${borderColor} 54 | ${borderRadius} 55 | ${bottom} 56 | ${boxShadow} 57 | ${color} 58 | ${display} 59 | ${flex} 60 | ${flexDirection} 61 | ${flexWrap} 62 | ${fontWeight} 63 | ${fontSize} 64 | ${height} 65 | ${justifyContent} 66 | ${left} 67 | ${lineHeight} 68 | ${maxHeight} 69 | ${maxWidth} 70 | ${minHeight} 71 | ${minWidth} 72 | ${order} 73 | ${position} 74 | ${right} 75 | ${size} 76 | ${space} 77 | ${top} 78 | ${textAlign} 79 | ${width} 80 | ${zIndex} 81 | ${props => 82 | props.clearfix && 83 | ` 84 | ::after { 85 | content: ""; 86 | display: table; 87 | clear: both; 88 | } 89 | `} 90 | `; 91 | 92 | Container.defaultProps = { 93 | omitProps: tag.defaultProps.omitProps.concat('float', 'clear', 'clearfix', 'overflow'), 94 | }; 95 | 96 | export default Container; 97 | -------------------------------------------------------------------------------- /src/components/Text.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { 3 | color, 4 | display, 5 | fontFamily, 6 | fontSize, 7 | fontStyle, 8 | fontWeight, 9 | lineHeight, 10 | letterSpacing, 11 | space, 12 | textAlign, 13 | } from 'styled-system'; 14 | import tag from 'clean-tag'; 15 | 16 | export const P = styled(tag.p)` 17 | ${color} 18 | ${display} 19 | ${fontFamily} 20 | ${fontSize} 21 | ${fontStyle} 22 | ${fontWeight} 23 | ${lineHeight} 24 | ${letterSpacing} 25 | ${space} 26 | ${textAlign} 27 | `; 28 | 29 | P.defaultProps = { 30 | omitProps: tag.defaultProps.omitProps.concat(['textTransform', 'whiteSpace', 'cursor']), 31 | fontSize: 'Paragraph', 32 | letterSpacing: '-0.2px', 33 | lineHeight: 'Paragraph', 34 | m: 0, 35 | }; 36 | 37 | export const Span = P.withComponent(tag.span); 38 | 39 | Span.defaultProps = { 40 | ...P.defaultProps, 41 | fontSize: 'inherit', 42 | lineHeight: 'inherit', 43 | }; 44 | 45 | export const H1 = P.withComponent(tag.h1); 46 | 47 | H1.defaultProps = { 48 | ...P.defaultProps, 49 | fontSize: 'H1', 50 | fontWeight: 'bold', 51 | letterSpacing: '-1.2px', 52 | lineHeight: 'H1', 53 | }; 54 | 55 | export const H2 = P.withComponent(tag.h2); 56 | 57 | H2.defaultProps = { 58 | ...P.defaultProps, 59 | fontSize: 'H2', 60 | fontWeight: 'bold', 61 | letterSpacing: '-0.4px', 62 | lineHeight: 'H2', 63 | }; 64 | 65 | export const H3 = P.withComponent(tag.h3); 66 | 67 | H3.defaultProps = { 68 | ...P.defaultProps, 69 | fontSize: 'H3', 70 | fontWeight: 'bold', 71 | letterSpacing: '-0.4px', 72 | lineHeight: 'H3', 73 | }; 74 | 75 | export const H4 = P.withComponent(tag.h4); 76 | 77 | H4.defaultProps = { 78 | ...P.defaultProps, 79 | fontSize: 'H4', 80 | fontWeight: 'bold', 81 | letterSpacing: '-0.2px', 82 | lineHeight: 'H4', 83 | }; 84 | 85 | export const H5 = P.withComponent(tag.h5); 86 | 87 | H5.defaultProps = { 88 | ...P.defaultProps, 89 | fontSize: 'H5', 90 | letterSpacing: '-0.2px', 91 | lineHeight: 'H5', 92 | textAlign: 'center', 93 | fontWeight: 500, 94 | color: 'black.800', 95 | }; 96 | -------------------------------------------------------------------------------- /src/pages/gift-cards.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Flex, Box } from '@rebass/grid'; 4 | import { chunk } from 'lodash'; 5 | 6 | import PrintableGiftCard from '../components/PrintableGiftCard'; 7 | import { OPENSOURCE_COLLECTIVE_ID } from '../constants/collectives'; 8 | 9 | export default class Home extends Component { 10 | static propTypes = { 11 | format: PropTypes.string.isRequired, 12 | cards: PropTypes.arrayOf( 13 | PropTypes.shape({ 14 | /** The amount in cents */ 15 | initialBalance: PropTypes.number.isRequired, 16 | /** Currency of the gift card (eg. `EUR`) */ 17 | currency: PropTypes.string.isRequired, 18 | /** UUID of the card */ 19 | uuid: PropTypes.string.isRequired, 20 | /** Expiry date */ 21 | expiryDate: PropTypes.string, 22 | }), 23 | ), 24 | }; 25 | 26 | static getInitialProps({ req }) { 27 | return { cards: req.body.cards, format: req.params.format }; 28 | } 29 | 30 | getScaleRatio(fileFormat) { 31 | // See https://github.com/marcbachmann/node-html-pdf/issues/110 32 | return fileFormat === 'pdf' ? 0.75 : 1; 33 | } 34 | 35 | getPageStyle(cardsPerPage, paginatedCards) { 36 | return { 37 | width: '8.27in', 38 | // Don't force height on last iteration to avoid blank page 39 | height: paginatedCards.length === cardsPerPage ? '11.69in' : 'auto', 40 | }; 41 | } 42 | 43 | render() { 44 | const { cards, format } = this.props; 45 | const scaleRatio = this.getScaleRatio(format); 46 | const cardsPerPage = 8; 47 | const chunks = chunk(cards, cardsPerPage); 48 | 49 | return ( 50 | 51 | {chunks.map((paginatedCards, idx) => ( 52 | 53 | 54 | {paginatedCards.map(c => ( 55 | 56 | 63 | 64 | ))} 65 | 66 | 67 | ))} 68 | 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/withIntl.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IntlProvider, addLocaleData, injectIntl } from 'react-intl'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import 'intl'; 6 | import 'intl/locale-data/jsonp/en.js'; // for old browsers without window.Intl 7 | 8 | // Register React Intl's locale data for the user's locale in the browser. This 9 | // locale data was added to the page by `pages/_document.js`. This only happens 10 | // once, on initial page load in the browser. 11 | if (typeof window !== 'undefined' && window.ReactIntlLocaleData) { 12 | Object.keys(window.ReactIntlLocaleData).forEach(lang => { 13 | addLocaleData(window.ReactIntlLocaleData[lang]); 14 | }); 15 | } 16 | 17 | export default Page => { 18 | const IntlPage = injectIntl(Page); 19 | 20 | return class WithIntl extends React.Component { 21 | static displayName = `WithIntl(${Page.displayName})`; 22 | 23 | static propTypes = { 24 | locale: PropTypes.string, 25 | messages: PropTypes.object, 26 | now: PropTypes.number, 27 | }; 28 | 29 | static defaultProps = { 30 | locale: 'en', 31 | }; 32 | 33 | // Note: when adding withIntl to a child component, getInitialProps doesn't get called 34 | // and it doesn't populate the messages in the props 35 | static async getInitialProps(context) { 36 | let props; 37 | if (typeof Page.getInitialProps === 'function') { 38 | props = await Page.getInitialProps(context); 39 | } 40 | 41 | // Get the `locale` and `messages` from the request object on the server. 42 | // In the browser, use the same values that the server serialized. 43 | const { req } = context; 44 | const { locale, messages } = req || window.__NEXT_DATA__.props.pageProps; 45 | 46 | // Always update the current time on page load/transition because the 47 | // will be a new instance even with pushState routing. 48 | const now = Date.now(); 49 | 50 | return { ...props, locale, messages, now }; 51 | } 52 | 53 | render() { 54 | const { locale, messages, now, ...props } = this.props; 55 | 56 | const intlProps = {}; 57 | if (locale) { 58 | intlProps.locale = locale; 59 | } 60 | if (messages) { 61 | intlProps.messages = messages; 62 | } 63 | 64 | // Note: If we don't add locale and messages as props, it falls back to the closest parent 65 | return ( 66 | 67 | 68 | 69 | ); 70 | } 71 | }; 72 | }; 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FEATURE MOVED TO https://github.com/opencollective/opencollective-pdf 2 | 3 | --- 4 | 5 | # Open Collective - Gift Cards Generator 6 | 7 | [![Circle CI](https://circleci.com/gh/opencollective/opencollective-giftcards-generator/tree/master.svg?style=shield)](https://circleci.com/gh/opencollective/opencollective-giftcards-generator/tree/master) 8 | [![Slack Status](https://slack.opencollective.org/badge.svg)](https://slack.opencollective.org) 9 | [![Dependency Status](https://david-dm.org/opencollective/opencollective-giftcards-generator/status.svg)](https://david-dm.org/opencollective/opencollective-giftcards-generator) 10 | [![Greenkeeper badge](https://badges.greenkeeper.io/opencollective/opencollective-giftcards-generator.svg)](https://greenkeeper.io/) 11 | [![codecov](https://codecov.io/gh/opencollective/opencollective-giftcards-generator/branch/master/graph/badge.svg)](https://codecov.io/gh/opencollective/opencollective-giftcards-generator) 12 | 13 | ## Foreword 14 | 15 | If you see a step below that could be improved (or is outdated), please update the instructions. We rarely go through this process ourselves, so your fresh pair of eyes and your recent experience with it, makes you the best candidate to improve them for other users. Thank you! 16 | 17 | ## Development 18 | 19 | ### Prerequisite 20 | 21 | Make sure you have Node.js version >= 10. 22 | We recommend using [nvm](https://github.com/creationix/nvm): `nvm use`. 23 | 24 | ### Install 25 | 26 | We recommend cloning the repository in a folder dedicated to `opencollective` projects. 27 | 28 | ``` 29 | git clone git@github.com:opencollective/opencollective-giftcards-generator.git opencollective/giftcards-generator 30 | cd opencollective/giftcards-generator 31 | npm install 32 | ``` 33 | 34 | ### Start 35 | 36 | - To start styleguide 37 | 38 | ``` 39 | npm run styleguide:dev 40 | ``` 41 | 42 | - To start the service: 43 | 44 | ``` 45 | npm run dev 46 | ``` 47 | 48 | Open http://localhost:3004/ to access the test/dev page. 49 | 50 | ### Tests 51 | 52 | You can run the tests using `npm test`. 53 | 54 | ## Deployment 55 | 56 | To deploy to staging or production, you need to be a core member of the Open Collective team. 57 | 58 | ### (Optional) Configure Slack token 59 | 60 | Setting a Slack token will post a message on `#engineering` with the changes you're 61 | about to deploy. It is not required, but you can activate it like this: 62 | 63 | 1. Go to https://api.slack.com/custom-integrations/legacy-tokens 64 | 2. Generate a token for the OpenCollective workspace 65 | 3. Add this token to your `.env` file: 66 | 67 | ```bash 68 | OC_SLACK_USER_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 69 | ``` 70 | 71 | ### Staging (now) 72 | 73 | ``` 74 | npm run deploy:staging 75 | ``` 76 | 77 | - URL: https://giftcards-generator-staging.opencollective.com/ 78 | 79 | ### Production (now) 80 | 81 | ``` 82 | npm run deploy:production 83 | ``` 84 | 85 | - URL: https://giftcards-generator.opencollective.com/ 86 | -------------------------------------------------------------------------------- /scripts/pre-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Description 4 | # =========== 5 | # 6 | # Pre-deploy hook. Does the following: 7 | # 1. Shows the commits about to be pushed 8 | # 2. Ask for confirmation (exit with 1 if not confirming) 9 | # 3. Notify Slack 10 | # 11 | # 12 | # Developing 13 | # ========== 14 | # 15 | # During development, the best way to test it is to call the script 16 | # directly with `./scripts/pre-deploy.sh staging|production`. You can also set 17 | # the `SLACK_CHANNEL` to your personnal channel so you don't flood the team. 18 | # To do that, right click on your own name in Slack, `Copy link`, then 19 | # only keep the last part of the URL. 20 | # 21 | # Or you can set `PUSH_TO_SLACK` to false to echo the payload instead of 22 | # sending it. 23 | # 24 | # ------------------------------------------------------------------------------ 25 | 26 | if [ "$#" -ne 1 ]; then 27 | echo "Usage: [DEPLOY_MSG='An optional custom deploy message'] $0 staging|production" 28 | exit 1 29 | fi 30 | 31 | SLACK_CHANNEL="CEZUS9WH3" 32 | 33 | # ---- Utils ---- 34 | 35 | function confirm() 36 | { 37 | echo -n "$@" 38 | read -e answer 39 | for response in y Y yes YES Yes Sure sure SURE OK ok Ok 40 | do 41 | if [ "$answer" == "$response" ] 42 | then 43 | return 0 44 | fi 45 | done 46 | 47 | # Any answer other than the list above is considerred a "no" answer 48 | return 1 49 | } 50 | 51 | function exit_success() 52 | { 53 | echo "🚀 Deploying now..." 54 | exit 0 55 | } 56 | 57 | # ---- Ask for confirmation ---- 58 | 59 | echo "ℹ️ You're about to deploy master branch to $1 server." 60 | confirm "❔ Are you sure (yes/no) > " || exit 1 61 | 62 | # ---- Slack notification ---- 63 | 64 | cd -- "$(dirname $0)/.." 65 | eval $(cat .env | grep OC_SLACK_USER_TOKEN=) 66 | 67 | if [ -z "$OC_SLACK_USER_TOKEN" ]; then 68 | # Emit a warning as we don't want the deploy to crash just because we 69 | # havn't setup a Slack token. Get yours on https://api.slack.com/custom-integrations/legacy-tokens 70 | echo "ℹ️ OC_SLACK_USER_TOKEN is not set, I will not notify Slack about this deploy 😞 (please do it manually)" 71 | exit_success 72 | fi 73 | 74 | if [ ! -z "$DEPLOY_MSG" ]; then 75 | CUSTOM_MESSAGE="-- _$(echo $DEPLOY_MSG | sed 's/"/\\\\"/g' | sed "s/'/\\\\'/g")_" 76 | fi 77 | 78 | read -d '' PAYLOAD << EOF 79 | { 80 | "channel": "${SLACK_CHANNEL}", 81 | "text": "📄 Deploying *GIFT CARDS GENERATOR* to *${1}* ${CUSTOM_MESSAGE}", 82 | "as_user": true 83 | } 84 | EOF 85 | 86 | curl \ 87 | -H "Content-Type: application/json; charset=utf-8" \ 88 | -H "Authorization: Bearer ${OC_SLACK_USER_TOKEN}" \ 89 | -d "$PAYLOAD" \ 90 | -s \ 91 | --fail \ 92 | https://slack.com/api/chat.postMessage \ 93 | &> /dev/null 94 | 95 | if [ $? -ne 0 ]; then 96 | echo "⚠️ I won't be able to notify slack. Please do it manually and check your OC_SLACK_USER_TOKEN" 97 | else 98 | echo "🔔 Slack notified about this deployment." 99 | fi 100 | 101 | # Always exit with 0 to continue the deploy even if slack notification failed 102 | exit_success 103 | -------------------------------------------------------------------------------- /src/server/controllers/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import pdf from 'html-pdf'; 4 | import sanitizeHtmlLib from 'sanitize-html'; 5 | import { logger } from '../logger'; 6 | 7 | /** 8 | * Sanitize HTML with only safe values 9 | */ 10 | const sanitizeHtml = html => { 11 | return sanitizeHtmlLib(html, { 12 | allowedTags: sanitizeHtmlLib.defaults.allowedTags.concat([ 13 | 'img', 14 | 'style', 15 | 'h1', 16 | 'h2', 17 | 'span', 18 | // SVG 19 | 'svg', 20 | 'path', 21 | 'polyline', 22 | 'line', 23 | ]), 24 | allowedSchemes: ['http', 'https', 'data'], 25 | allowedAttributes: Object.assign(sanitizeHtmlLib.defaults.allowedAttributes, { 26 | '*': ['style', 'class', 'height', 'width'], 27 | td: ['width'], 28 | img: ['src', 'srcset', 'alt'], 29 | // SVG 30 | svg: [ 31 | 'shape-rendering', 32 | 33 | 'viewBox', 34 | 'style', 35 | 'aria-hidden', 36 | 'stroke-linecap', 37 | 'stroke-linejoin', 38 | 'focusable', 39 | 'fill', 40 | 'stroke', 41 | 'color', 42 | ], 43 | path: ['fill', 'd'], 44 | polyline: ['points'], 45 | line: ['x1', 'x2', 'y1', 'y2'], 46 | }), 47 | parser: { 48 | lowerCaseAttributeNames: false, 49 | }, 50 | }); 51 | }; 52 | 53 | export const renderMany = async (req, res, next) => { 54 | const { filename, format } = req.params; 55 | 56 | const hasInvalidFileType = !['pdf', 'html'].includes(format); 57 | if (hasInvalidFileType || !req.body || !req.body.cards || !Array.isArray(req.body.cards)) { 58 | res.send({ error: 'Invalid parameters' }); 59 | return; 60 | } 61 | 62 | const rawHtml = await req.app.renderToHTML(req, res, '/gift-cards'); 63 | const sendRaw = ['1', 'true'].includes(req.query.raw); 64 | const html = sendRaw ? rawHtml : sanitizeHtml(rawHtml); 65 | 66 | if (format === 'html') { 67 | res.send(html); 68 | } else if (format === 'pdf') { 69 | const pdfOptions = { format: 'A4', renderDelay: 500 }; 70 | res.setHeader('content-type', 'application/pdf'); 71 | res.setHeader('content-disposition', `inline; filename="${filename}.pdf"`); // or attachment? 72 | pdf.create(html, pdfOptions).toStream((err, stream) => { 73 | if (err) { 74 | logger.error('>>> Error while generating pdf at %s', req.url, err); 75 | return next(err); 76 | } 77 | stream.pipe(res); 78 | }); 79 | } 80 | }; 81 | 82 | export async function testFixture(req, res, next) { 83 | const { filename } = req.params; 84 | try { 85 | const fixturesPath = '../../../test/__fixtures__/'; 86 | const filePath = path.join(__dirname, fixturesPath, `${path.basename(filename)}.json`); 87 | const content = await fs.readJson(filePath); 88 | return renderMany({ ...req, body: content }, res, next); 89 | } catch (e) { 90 | logger.error('>>> transactions.transactionInvoice error', e.message); 91 | logger.debug(e); 92 | if (e.message.match(/No collective found/)) { 93 | return res.status(404).send('Not found'); 94 | } else { 95 | return res.status(500).send(`Internal Server Error: ${e.message}`); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { saveAs } from 'file-saver'; 3 | 4 | import StyledButton from '../components/StyledButton'; 5 | import StyledInput from '../components/StyledInput'; 6 | 7 | export default class Home extends Component { 8 | state = { formInput: '', loading: false, error: null }; 9 | 10 | async generateAndDownload(format) { 11 | const filename = `giftcards.${format}`; 12 | const jsonString = `{ "cards": ${this.state.formInput.trim()} }`; 13 | try { 14 | const payload = JSON.parse(jsonString); 15 | this.setState({ loading: true }); 16 | const response = await fetch(`/render-many/${filename}`, { 17 | method: 'POST', 18 | headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, 19 | body: JSON.stringify(payload), 20 | }); 21 | const file = await response.blob(); 22 | saveAs(file, filename); 23 | this.setState({ loading: false }); 24 | } catch (e) { 25 | this.setState({ error: e.message, loading: false }); 26 | console.log('JSON payload', jsonString); 27 | } 28 | } 29 | 30 | render() { 31 | const { formInput, loading, error } = this.state; 32 | const readyToSubmit = formInput.length > 0; 33 | 34 | return ( 35 |
36 |

Open Collective - Gift card generator

37 |
38 |

Test Payloads

39 | 47 |
48 |

Generate directly from data

49 |

Paste the array from GraphQL API here.

50 |
51 | See example 52 |
 53 |             {`
 54 |           [
 55 |             {
 56 |               "id": null,
 57 |               "name": "€5 Gift Card from BrusselsTogether ASBL",
 58 |               "uuid": "84c3b302-93eb-4af5-9304-acf8f5fe83eb",
 59 |               "description": "€5 Gift Card from BrusselsTogether ASBL",
 60 |               "initialBalance": 500,
 61 |               "monthlyLimitPerMember": null,
 62 |               "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)",
 63 |               "currency": "EUR",
 64 |               "data": null,
 65 |               "__typename": "PaymentMethodType"
 66 |             },
 67 |             {
 68 |               "id": null,
 69 |               "name": "€5 Gift Card from BrusselsTogether ASBL",
 70 |               "uuid": "a5665a61-ec24-41fe-904c-5b477f469622",
 71 |               "description": "€5 Gift Card from BrusselsTogether ASBL",
 72 |               "initialBalance": 500,
 73 |               "monthlyLimitPerMember": null,
 74 |               "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)",
 75 |               "currency": "EUR",
 76 |               "data": null,
 77 |               "__typename": "PaymentMethodType"
 78 |             }
 79 |           ]
 80 |           `}
 81 |           
82 |
83 | this.setState({ formInput: e.target.value })} 88 | /> 89 |
90 | this.generateAndDownload('html')} 96 | > 97 | Generate HTML 98 | 99 | this.generateAndDownload('pdf')} 105 | > 106 | Generate PDF 107 | 108 | {error &&
{error}
} 109 |
110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/components/StyledButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled, { css } from 'styled-components'; 4 | import tag from 'clean-tag'; 5 | import { 6 | backgroundColor, 7 | border, 8 | borderRadius, 9 | color, 10 | display, 11 | fontFamily, 12 | fontSize, 13 | fontWeight, 14 | height, 15 | minWidth, 16 | maxWidth, 17 | space, 18 | textAlign, 19 | width, 20 | themeGet, 21 | } from 'styled-system'; 22 | import { buttonSize, buttonStyle } from '../constants/theme'; 23 | import StyledSpinner from './StyledSpinner'; 24 | 25 | /** 26 | * styled-component button using styled-system 27 | * 28 | * @see See [styled-system docs](https://github.com/jxnblk/styled-system/blob/master/docs/api.md) for usage of those props 29 | */ 30 | const StyledButtonContent = styled(tag.button)` 31 | appearance: none; 32 | border: none; 33 | cursor: pointer; 34 | outline: 0; 35 | border: 1px solid; 36 | border-radius: 100px; 37 | 38 | ${buttonStyle} 39 | ${buttonSize} 40 | 41 | ${props => 42 | props.asLink && 43 | css` 44 | background: none !important; 45 | color: inherit; 46 | border: none; 47 | padding: 0; 48 | font: inherit; 49 | color: ${themeGet('colors.primary.500')}; 50 | 51 | &:active { 52 | color: ${themeGet('colors.primary.400')}; 53 | } 54 | `} 55 | 56 | ${backgroundColor} 57 | ${border} 58 | ${borderRadius} 59 | ${color} 60 | ${display} 61 | ${fontFamily} 62 | ${fontSize} 63 | ${fontWeight} 64 | ${minWidth} 65 | ${maxWidth} 66 | ${space} 67 | ${textAlign} 68 | ${width} 69 | ${height} 70 | `; 71 | 72 | const StyledButton = ({ loading, ...props }) => 73 | !loading ? ( 74 | 75 | ) : ( 76 | 77 | 78 | 79 | ); 80 | 81 | StyledButton.propTypes = { 82 | /** @ignore */ 83 | omitProps: PropTypes.arrayOf(PropTypes.string), 84 | /** 85 | * Based on the design system theme 86 | */ 87 | buttonSize: PropTypes.oneOf(['small', 'medium', 'large']), 88 | /** 89 | * Based on the design system theme 90 | */ 91 | buttonStyle: PropTypes.oneOf(['primary', 'standard', 'dark']), 92 | /** 93 | * From styled-system: accepts any css 'display' value 94 | */ 95 | display: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 96 | /** 97 | * From styled-system: accepts any css 'font-weight' value 98 | */ 99 | fontWeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 100 | /** 101 | * From styled-system: accepts any css 'min-width' value 102 | */ 103 | minWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 104 | /** 105 | * From styled-system: accepts any css 'max-width' value 106 | */ 107 | maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 108 | /** 109 | * styled-system prop: adds margin & padding props 110 | * see: https://github.com/jxnblk/styled-system/blob/master/docs/api.md#space 111 | */ 112 | space: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 113 | /** 114 | * From styled-system: accepts any css 'text-align' value 115 | */ 116 | textAlign: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 117 | /** 118 | * From styled-system: accepts any css 'width' value 119 | */ 120 | width: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 121 | /** 122 | * Show a loading spinner on button 123 | */ 124 | loading: PropTypes.bool, 125 | /** If true, will display a link instead of a button */ 126 | asLink: PropTypes.bool, 127 | }; 128 | 129 | StyledButton.defaultProps = { 130 | omitProps: tag.defaultProps.omitProps.concat('buttonStyle', 'buttonSize', 'asLink'), 131 | buttonSize: 'medium', 132 | buttonStyle: 'standard', 133 | loading: false, 134 | }; 135 | 136 | /** @component */ 137 | export default StyledButton; 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opencollective-giftcards-generator", 3 | "version": "1.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/opencollective/opencollective-giftcards-generator.git" 7 | }, 8 | "private": true, 9 | "engines": { 10 | "node": "11.8.0", 11 | "npm": "6.5.0" 12 | }, 13 | "dependencies": { 14 | "@rebass/grid": "6.0.0", 15 | "clean-tag": "3.1.0", 16 | "cors": "~2.8.5", 17 | "debug": "~4.1.1", 18 | "dotenv": "~7.0.0", 19 | "express": "~4.16.3", 20 | "express-winston": "~3.1.0", 21 | "file-saver": "2.0.2", 22 | "fs-extra": "^7.0.1", 23 | "html-pdf": "~2.2.0", 24 | "i18n-iso-countries": "~4.3.1", 25 | "intl": "~1.2.5", 26 | "lodash": "~4.17.13", 27 | "moment": "~2.24.0", 28 | "next": "~8.0.1", 29 | "next-routes": "~1.4.2", 30 | "polished": "~3.3.0", 31 | "prop-types": "~15.7.2", 32 | "qrcode.react": "0.9.3", 33 | "react": "~16.10.2", 34 | "react-dom": "~16.8.6", 35 | "react-intl": "~2.8.0", 36 | "react-styleguidist": "9.0.4", 37 | "rebass": "~3.0.1", 38 | "sanitize-html": "~1.20.1", 39 | "styled-components": "~4.1.3", 40 | "styled-icons": "7.14.0", 41 | "styled-system": "~4.2.2", 42 | "winston": "~3.2.1" 43 | }, 44 | "scripts": { 45 | "start": "NODE_ENV=production node dist/server", 46 | "dev": "nodemon --inspect --exec babel-node -- src/server/index.js", 47 | "build": "npm run build:clean && npm run build:server && npm run build:next", 48 | "build:clean": "rm -rf dist src/.next", 49 | "build:server": "babel --copy-files ./src --out-dir ./dist --ignore src/templates/widget.js,src/**/__tests__/*,src/.next/*", 50 | "build:next": "next build dist", 51 | "test": "TZ=UTC jest", 52 | "test:update": "TZ=UTC jest -u", 53 | "test:watch": "TZ=UTC jest --watch", 54 | "lint": "eslint \"src/**/*.js\" \"test/**/*.js\"", 55 | "lint:fix": "npm run lint -- --fix", 56 | "lint:quiet": "npm run lint -- --quiet", 57 | "prettier": "prettier \"src/**/*.js\" \"test/**/*.js\" --write", 58 | "deploy:staging": "./scripts/pre-deploy.sh staging && now -e GENERATOR_URL=https://giftcards-generator-staging.opencollective.com -e WEBSITE_URL=https://staging.opencollective.com && now alias giftcards-generator-staging.opencollective.com", 59 | "deploy:production": "./scripts/pre-deploy.sh production && now -e GENERATOR_URL=https://giftcards-generator.opencollective.com -e WEBSITE_URL=https://opencollective.com && now alias giftcards-generator.opencollective.com", 60 | "styleguide:dev": "styleguidist server", 61 | "styleguide:build": "mkdir -p src/static && cp -r src/static styleguide/static/ && STATIC_ASSETS_URL=/static styleguidist build" 62 | }, 63 | "devDependencies": { 64 | "@babel/cli": "^7.4.4", 65 | "@babel/core": "^7.6.4", 66 | "@babel/node": "^7.2.2", 67 | "@babel/preset-env": "^7.4.4", 68 | "babel-core": "^7.0.0-bridge.0", 69 | "babel-eslint": "^10.0.1", 70 | "babel-plugin-lodash": "^3.3.4", 71 | "babel-plugin-react-intl": "^3.0.1", 72 | "babel-plugin-styled-components": "^1.10.0", 73 | "codecov": "^3.5.0", 74 | "commitizen": "^3.0.2", 75 | "cz-conventional-changelog": "^2.1.0", 76 | "eslint": "^5.14.1", 77 | "eslint-config-opencollective": "^2.0.0", 78 | "eslint-plugin-babel": "^5.2.1", 79 | "eslint-plugin-graphql": "^3.0.3", 80 | "eslint-plugin-import": "^2.17.2", 81 | "eslint-plugin-node": "^8.0.1", 82 | "eslint-plugin-react": "^7.16.0", 83 | "eslint-plugin-react-hooks": "^1.6.0", 84 | "file-loader": "^3.0.1", 85 | "jest": "^24.9.0", 86 | "jest-junit": "^6.4.0", 87 | "jest-styled-components": "^6.3.1", 88 | "nodemon": "^1.19.3", 89 | "prettier": "^1.16.4", 90 | "react-test-renderer": "^16.8.6", 91 | "url-loader": "^1.1.2" 92 | }, 93 | "config": { 94 | "commitizen": { 95 | "path": "./node_modules/cz-conventional-changelog" 96 | } 97 | }, 98 | "nodemonConfig": { 99 | "watch": [ 100 | "src/server" 101 | ] 102 | }, 103 | "jest": { 104 | "setupTestFrameworkScriptFile": "./test/setup.js", 105 | "testPathIgnorePatterns": [ 106 | "/node_modules/", 107 | "/test/__fixtures__/", 108 | "/test/__helpers__/", 109 | "/test/__mocks__/", 110 | "/test/setup.js" 111 | ], 112 | "moduleNameMapper": { 113 | "\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$": "/test/__mocks__/fileMock.js" 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/StyledInput.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import styled from 'styled-components'; 3 | import { 4 | background, 5 | borders, 6 | borderColor, 7 | borderRadius, 8 | color, 9 | display, 10 | flex, 11 | fontSize, 12 | fontWeight, 13 | maxWidth, 14 | minWidth, 15 | space, 16 | textAlign, 17 | themeGet, 18 | width, 19 | height, 20 | lineHeight, 21 | } from 'styled-system'; 22 | import tag from 'clean-tag'; 23 | import { buttonSize, buttonStyle } from '../constants/theme'; 24 | 25 | const getBorderColor = ({ error, success }) => { 26 | if (error) { 27 | return themeGet('colors.red.500'); 28 | } 29 | 30 | if (success) { 31 | return themeGet('colors.green.300'); 32 | } 33 | 34 | return themeGet('colors.black.300'); 35 | }; 36 | 37 | /** 38 | * styled-component input tag using styled-system 39 | * 40 | * @see See [styled-system docs](https://github.com/jxnblk/styled-system/blob/master/docs/api.md) for usage of those props 41 | */ 42 | const StyledInput = styled(tag.input)` 43 | ${background} 44 | ${borders} 45 | ${borderColor} 46 | ${borderRadius} 47 | ${color} 48 | ${display} 49 | ${flex} 50 | ${fontSize} 51 | ${fontWeight} 52 | ${lineHeight} 53 | ${maxWidth} 54 | ${minWidth} 55 | ${space} 56 | ${textAlign} 57 | ${width} 58 | ${height} 59 | 60 | border-color: ${getBorderColor}; 61 | border-style: ${props => (props.bare ? 'none' : 'solid')}; 62 | box-sizing: border-box; 63 | outline: none; 64 | 65 | &:disabled { 66 | background-color: ${themeGet('colors.black.50')}; 67 | cursor: not-allowed; 68 | } 69 | 70 | &:focus, &:hover:not(:disabled) { 71 | border-color: ${themeGet('colors.primary.300')}; 72 | } 73 | 74 | &::placeholder { 75 | color: ${themeGet('colors.black.400')}; 76 | } 77 | `; 78 | 79 | StyledInput.propTypes = { 80 | /** @ignore */ 81 | omitProps: PropTypes.arrayOf(PropTypes.string), 82 | /** true to hide styled borders */ 83 | bare: PropTypes.bool, 84 | /** styled-system prop: accepts any css 'border' value */ 85 | border: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 86 | /** styled-system prop: accepts any css 'border-color' value */ 87 | borderColor: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 88 | /** styled-system prop: accepts any css 'border-radius' value */ 89 | borderRadius: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 90 | /** Show disabled state for field */ 91 | disabled: PropTypes.bool, 92 | /** Show error state for field */ 93 | error: PropTypes.bool, 94 | /** styled-system prop: accepts any css 'font-size' value */ 95 | fontSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 96 | /** styled-system prop: accepts any css 'min-width' value */ 97 | maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 98 | /** styled-system prop: accepts any css 'max-width' value */ 99 | minWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 100 | /** @ignore */ 101 | px: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 102 | /** @ignore */ 103 | py: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 104 | /** 105 | * styled-system prop: adds margin & padding props 106 | * see: https://github.com/jxnblk/styled-system/blob/master/docs/api.md#space 107 | */ 108 | space: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 109 | /** Show success state for field */ 110 | success: PropTypes.bool, 111 | /** styled-system prop: accepts any css 'width' value */ 112 | width: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.array]), 113 | }; 114 | 115 | StyledInput.defaultProps = { 116 | omitProps: tag.defaultProps.omitProps.concat('buttonStyle', 'buttonSize', 'bare', 'error'), 117 | border: '1px solid', 118 | borderColor: 'black.300', 119 | borderRadius: '4px', 120 | px: 3, 121 | py: 2, 122 | lineHeight: '1.5', 123 | fontSize: 'Paragraph', 124 | }; 125 | 126 | export const TextInput = styled(StyledInput)``; 127 | 128 | TextInput.defaultProps = { 129 | ...StyledInput.defaultProps, 130 | type: 'text', 131 | }; 132 | 133 | export const SubmitInput = styled(StyledInput)` 134 | ${buttonStyle}; 135 | ${buttonSize}; 136 | `; 137 | 138 | SubmitInput.defaultProps = { 139 | omitProps: tag.defaultProps.omitProps.concat('buttonStyle', 'buttonSize', 'bare'), 140 | buttonStyle: 'primary', 141 | buttonSize: 'large', 142 | fontWeight: 'bold', 143 | type: 'submit', 144 | }; 145 | 146 | /** @component */ 147 | export default StyledInput; 148 | -------------------------------------------------------------------------------- /src/constants/theme.js: -------------------------------------------------------------------------------- 1 | import { variant } from 'styled-system'; 2 | import { transparentize } from 'polished'; 3 | 4 | export const colors = { 5 | black: { 6 | 900: '#090A0A', 7 | 800: '#313233', 8 | 700: '#4E5052', 9 | 600: '#76777A', 10 | 500: '#9D9FA3', 11 | 400: '#C4C7CC', 12 | 300: '#DCDEE0', 13 | 200: '#E8E9EB', 14 | 100: '#F2F3F5', 15 | 50: '#F7F8FA', 16 | transparent: { 17 | 90: 'rgba(19, 20, 20, 0.90)', 18 | 80: 'rgba(19, 20, 20, 0.80)', 19 | 40: 'rgba(19, 20, 20, 0.4)', 20 | 20: 'rgba(19, 20, 20, 0.2)', 21 | 8: 'rgba(19, 20, 20, 0.08)', 22 | }, 23 | }, 24 | green: { 25 | 700: '#00A34C', 26 | 500: '#00B856', 27 | 300: '#6CE0A2', 28 | 100: '#E6FAEF', 29 | }, 30 | primary: { 31 | 800: '#0041A3', 32 | 700: '#145ECC', 33 | 500: '#3385FF', 34 | 400: '#66A3FF', 35 | 300: '#99C9FF', 36 | 200: '#B8DEFF', 37 | 100: '#EBF4FF', 38 | 50: '#F0F8FF', 39 | }, 40 | red: { 41 | 700: '#CC1836', 42 | 500: '#F53152', 43 | 300: '#FF99AA', 44 | 100: '#FFF2F4', 45 | }, 46 | secondary: { 47 | 700: '#9BC200', 48 | 500: '#AFDB00', 49 | 400: '#D3E58A', 50 | 100: '#F2FAD2', 51 | }, 52 | white: { 53 | full: '#FFFFFF', 54 | transparent: { 55 | 72: 'rgba(255, 255, 255, 0.72)', 56 | 48: 'rgba(255, 255, 255, 0.48)', 57 | }, 58 | }, 59 | yellow: { 60 | 700: '#E0B700', 61 | 500: '#F5CC00', 62 | 300: '#FFEB85', 63 | 100: '#FFFBE5', 64 | }, 65 | }; 66 | 67 | const fontSizes = { 68 | H1: 52, 69 | H2: 40, 70 | H3: 32, 71 | H4: 24, 72 | H5: 20, 73 | H6: 9, 74 | LeadParagraph: 16, 75 | Paragraph: 14, 76 | Caption: 12, 77 | Tiny: 10, 78 | }; 79 | 80 | const lineHeights = { 81 | H1: '56px', 82 | H2: '44px', 83 | H3: '36px', 84 | H4: '32px', 85 | H5: '24px', 86 | H6: '14px', 87 | LeadParagraph: '24px', 88 | Paragraph: '20px', 89 | Caption: '18px', 90 | Tiny: '14px', 91 | }; 92 | 93 | // using default space values from styled-system 94 | const space = [0, 4, 8, 16, 32, 64, 128, 256, 512]; 95 | 96 | const theme = { 97 | colors, 98 | fontSizes, 99 | lineHeights, 100 | space, 101 | breakpoints: [ 102 | '40em', // 640px - mobile 103 | '52em', // 832px - tablet 104 | '64em', // 1024px - desktop 105 | '88em', // 1408px - widescreen 106 | ], 107 | buttons: { 108 | standard: { 109 | backgroundColor: 'white', 110 | border: '1px solid', 111 | borderColor: colors.black[300], 112 | borderRadius: '100px', 113 | color: colors.black[600], 114 | 115 | '&:hover': { 116 | borderColor: colors.primary[300], 117 | color: colors.primary[400], 118 | }, 119 | 120 | '&:focus': { 121 | backgroundColor: 'white', 122 | borderColor: colors.primary[400], 123 | }, 124 | 125 | '&:active': { 126 | backgroundColor: colors.primary[500], 127 | borderColor: colors.primary[500], 128 | color: 'white', 129 | }, 130 | 131 | '&:disabled': { 132 | backgroundColor: colors.black[50], 133 | borderColor: colors.black[200], 134 | color: colors.black[300], 135 | cursor: 'not-allowed', 136 | }, 137 | }, 138 | 139 | primary: { 140 | backgroundColor: colors.primary[500], 141 | border: '1px solid', 142 | borderColor: colors.primary[500], 143 | borderRadius: '100px', 144 | color: 'white', 145 | 146 | '&:hover': { 147 | backgroundColor: colors.primary[700], 148 | color: 'white', 149 | }, 150 | 151 | '&:focus': { 152 | backgroundColor: colors.primary[500], 153 | borderColor: colors.primary[700], 154 | }, 155 | 156 | '&:active': { 157 | backgroundColor: colors.primary[800], 158 | borderColor: colors.primary[800], 159 | color: 'white', 160 | }, 161 | 162 | '&:disabled': { 163 | backgroundColor: colors.primary[50], 164 | color: colors.primary[200], 165 | borderColor: colors.primary[50], 166 | cursor: 'not-allowed', 167 | }, 168 | }, 169 | 170 | dark: { 171 | backgroundColor: colors.black[900], 172 | color: colors.white.full, 173 | border: '1px solid', 174 | borderColor: colors.black[900], 175 | borderRadius: '100px', 176 | }, 177 | }, 178 | buttonSizes: { 179 | large: { 180 | fontSize: toPx(fontSizes.LeadParagraph), 181 | lineHeight: toPx(fontSizes.LeadParagraph + 2), 182 | paddingBottom: toPx(space[3]), 183 | paddingLeft: toPx(space[5]), 184 | paddingRight: toPx(space[5]), 185 | paddingTop: toPx(space[3]), 186 | }, 187 | 188 | medium: { 189 | fontSize: toPx(fontSizes.Paragraph), 190 | lineHeight: toPx(fontSizes.Paragraph + 7), 191 | paddingBottom: toPx(space[2]), 192 | paddingLeft: toPx(space[3]), 193 | paddingRight: toPx(space[3]), 194 | paddingTop: toPx(space[2]), 195 | }, 196 | 197 | small: { 198 | fontSize: toPx(fontSizes.Caption), 199 | lineHeight: toPx(fontSizes.Caption + 2), 200 | paddingBottom: toPx(space[1]), 201 | paddingLeft: toPx(space[2]), 202 | paddingRight: toPx(space[2]), 203 | paddingTop: toPx(space[1]), 204 | }, 205 | }, 206 | messageTypes: { 207 | white: { 208 | backgroundColor: colors.white.full, 209 | borderColor: colors.black[200], 210 | }, 211 | dark: { 212 | backgroundColor: transparentize(0.2, colors.black[900]), 213 | borderColor: colors.black[900], 214 | color: colors.white.full, 215 | }, 216 | info: { 217 | backgroundColor: colors.primary[50], 218 | borderColor: colors.primary[500], 219 | color: colors.primary[700], 220 | }, 221 | success: { 222 | backgroundColor: colors.green[100], 223 | borderColor: colors.green[500], 224 | color: colors.green[700], 225 | }, 226 | warning: { 227 | backgroundColor: colors.yellow[100], 228 | borderColor: colors.yellow[700], 229 | color: colors.yellow[700], 230 | }, 231 | error: { 232 | backgroundColor: colors.red[100], 233 | borderColor: colors.red[500], 234 | color: colors.red[700], 235 | }, 236 | }, 237 | }; 238 | 239 | export const buttonStyle = variant({ 240 | key: 'buttons', 241 | prop: 'buttonStyle', 242 | }); 243 | 244 | export const buttonSize = variant({ 245 | key: 'buttonSizes', 246 | prop: 'buttonSize', 247 | }); 248 | 249 | export const messageType = variant({ 250 | key: 'messageTypes', 251 | prop: 'type', 252 | }); 253 | 254 | export default theme; 255 | 256 | function toPx(value) { 257 | return `${value}px`; 258 | } 259 | -------------------------------------------------------------------------------- /src/components/PrintableGiftCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Flex, Box } from '@rebass/grid'; 4 | import { FormattedMessage, FormattedDate } from 'react-intl'; 5 | import QRCode from 'qrcode.react'; 6 | import styled from 'styled-components'; 7 | import { borderRadius, fontSize } from 'styled-system'; 8 | 9 | import { ExternalLink } from 'styled-icons/feather/ExternalLink'; 10 | import { imgUrl } from '../lib/utils'; 11 | import { WEBSITE_URL } from '../constants/env'; 12 | import Container from './Container'; 13 | import StyledHr from './StyledHr'; 14 | import { P, Span } from './Text'; 15 | import Currency from './Currency'; 16 | 17 | const Card = styled(Box)` 18 | font-family: Helvetica, sans-serif; 19 | width: 85.60mm; 20 | height: 53.98mm; 21 | position: relative; 22 | overflow: hidden; 23 | background-image: url('${imgUrl('oc-gift-card-front-straightened.png')}'); 24 | background-size: 100%; 25 | background-repeat: no-repeat; 26 | color: white; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: space-between; 30 | outline: 0.1em dashed rgba(62, 130, 230, 0.15); 31 | margin: 2em; 32 | 33 | @media print { 34 | break-inside: avoid; 35 | } 36 | 37 | ${borderRadius}; 38 | ${fontSize}; 39 | `; 40 | 41 | const OpenCollectiveLogo = styled.img` 42 | width: 3em; 43 | height: 3em; 44 | `; 45 | 46 | /** 47 | * A static gift card meant to be printed to be offered to someone. It has standard 48 | * business card resolution (3.5in x 2in) but is rendered two time bigger for HDPI. 49 | */ 50 | const PrintableGiftCard = ({ 51 | amount, 52 | currency, 53 | code, 54 | expiryDate, 55 | tagline, 56 | withQRCode, 57 | isLimitedToOpenSource, 58 | ...styleProps 59 | }) => { 60 | const redeemUrl = `${WEBSITE_URL}/redeem/${code}`; 61 | const basePaddingX = '0.8em'; 62 | const paddingTop = '1em'; 63 | 64 | return ( 65 | 66 | {/** Header */} 67 | 68 | 69 | 70 | 71 | 72 |

73 | Open Collective 74 |

75 | {tagline && ( 76 |

77 | {tagline} 78 |

79 | )} 80 |
81 |
82 | 83 | 92 | 93 | 94 |
95 | 96 | 97 |

98 | {isLimitedToOpenSource && ( 99 | 100 | Limited to collectives hosted by the Open Source Collective 501c6 (Non Profit). 101 |
102 |
103 | )} 104 | {expiryDate && ( 105 | , 110 | }} 111 | /> 112 | )} 113 |

114 |
115 |
116 | 117 | {/** Footer */} 118 | 119 | {/** Left */} 120 | 121 |

122 | 123 | 124 | opencollective.com/redeem/ 125 | 126 | 127 | {code} 128 | 129 |

130 |
131 | 132 | {/** Right */} 133 | 134 | 135 | {withQRCode && ( 136 | 137 | {/** Use 2x the real size to get a better resolution */} 138 | 145 | 146 | )} 147 | 148 | 149 | 150 | 151 | 152 | 153 | {currency} 154 | 155 | 156 | 157 | 158 | 159 |
160 |
161 | ); 162 | }; 163 | 164 | PrintableGiftCard.propTypes = { 165 | /** The amount in cents */ 166 | amount: PropTypes.number.isRequired, 167 | /** Currency of the gift card (eg. `EUR`) */ 168 | currency: PropTypes.string.isRequired, 169 | /** The 8 characters code of the gift card */ 170 | code: PropTypes.string.isRequired, 171 | /** Expiry date */ 172 | expiryDate: PropTypes.string, 173 | /** Border radius for the card */ 174 | borderRadius: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 175 | /** If true, QR code will be displayed */ 176 | withQRCode: PropTypes.bool, 177 | /** The tagline displayed under Open Collective logo */ 178 | tagline: PropTypes.node, 179 | /** Main font size */ 180 | fontSize: PropTypes.string, 181 | /** If true, a messagewill indicate that the gift card is limited to opensource host */ 182 | isLimitedToOpenSource: PropTypes.bool, 183 | }; 184 | 185 | PrintableGiftCard.defaultProps = { 186 | tagline: 'Transparent funding for open source', 187 | withQRCode: false, 188 | borderRadius: '0px', 189 | fontSize: '0.14in', 190 | }; 191 | 192 | export default PrintableGiftCard; 193 | -------------------------------------------------------------------------------- /test/__fixtures__/payload_simple_cards.json: -------------------------------------------------------------------------------- 1 | { 2 | "cards": [ 3 | { 4 | "id": null, 5 | "name": "€5 Gift Card from BrusselsTogether ASBL", 6 | "uuid": "84c3b302-93eb-4af5-9304-acf8f5fe83eb", 7 | "description": "€5 Gift Card from BrusselsTogether ASBL", 8 | "initialBalance": 2500, 9 | "monthlyLimitPerMember": null, 10 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 11 | "currency": "EUR", 12 | "data": null, 13 | "limitedToHostCollectiveIds": [11004], 14 | "__typename": "PaymentMethodType" 15 | }, 16 | { 17 | "id": null, 18 | "name": "€5 Gift Card from BrusselsTogether ASBL", 19 | "uuid": "a5665a61-ec24-41fe-904c-5b477f469622", 20 | "description": "€5 Gift Card from BrusselsTogether ASBL", 21 | "initialBalance": 2500, 22 | "monthlyLimitPerMember": null, 23 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 24 | "currency": "EUR", 25 | "data": null, 26 | "limitedToHostCollectiveIds": [11004], 27 | "__typename": "PaymentMethodType" 28 | }, 29 | { 30 | "id": null, 31 | "name": "€5 Gift Card from BrusselsTogether ASBL", 32 | "uuid": "7b5caf02-4145-4752-9830-29fa611ae2bb", 33 | "description": "€5 Gift Card from BrusselsTogether ASBL", 34 | "initialBalance": 2500, 35 | "monthlyLimitPerMember": null, 36 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 37 | "currency": "EUR", 38 | "data": null, 39 | "limitedToHostCollectiveIds": [11004], 40 | "__typename": "PaymentMethodType" 41 | }, 42 | { 43 | "id": null, 44 | "name": "€5 Gift Card from BrusselsTogether ASBL", 45 | "uuid": "017f5faf-3003-43f9-a561-f5837eaa1d99", 46 | "description": "€5 Gift Card from BrusselsTogether ASBL", 47 | "initialBalance": 2500, 48 | "monthlyLimitPerMember": null, 49 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 50 | "currency": "EUR", 51 | "data": null, 52 | "limitedToHostCollectiveIds": [11004], 53 | "__typename": "PaymentMethodType" 54 | }, 55 | { 56 | "id": null, 57 | "name": "€5 Gift Card from BrusselsTogether ASBL", 58 | "uuid": "33deafc9-3185-4f25-8d92-71afb863e9d5", 59 | "description": "€5 Gift Card from BrusselsTogether ASBL", 60 | "initialBalance": 2500, 61 | "monthlyLimitPerMember": null, 62 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 63 | "currency": "EUR", 64 | "data": null, 65 | "limitedToHostCollectiveIds": [11004], 66 | "__typename": "PaymentMethodType" 67 | }, 68 | { 69 | "id": null, 70 | "name": "€5 Gift Card from BrusselsTogether ASBL", 71 | "uuid": "8a1e8fe8-6f45-4395-8f5f-d8b14ab12747", 72 | "description": "€5 Gift Card from BrusselsTogether ASBL", 73 | "initialBalance": 2500, 74 | "monthlyLimitPerMember": null, 75 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 76 | "currency": "EUR", 77 | "data": null, 78 | "limitedToHostCollectiveIds": [11004], 79 | "__typename": "PaymentMethodType" 80 | }, 81 | { 82 | "id": null, 83 | "name": "€5 Gift Card from BrusselsTogether ASBL", 84 | "uuid": "4ebece47-5a96-4d7f-8cda-a5767c7b7cf7", 85 | "description": "€5 Gift Card from BrusselsTogether ASBL", 86 | "initialBalance": 2500, 87 | "monthlyLimitPerMember": null, 88 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 89 | "currency": "EUR", 90 | "data": null, 91 | "limitedToHostCollectiveIds": [11004], 92 | "__typename": "PaymentMethodType" 93 | }, 94 | { 95 | "id": null, 96 | "name": "€5 Gift Card from BrusselsTogether ASBL", 97 | "uuid": "8262ecc6-3b54-4c43-bfa9-cde8978ad593", 98 | "description": "€5 Gift Card from BrusselsTogether ASBL", 99 | "initialBalance": 2500, 100 | "monthlyLimitPerMember": null, 101 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 102 | "currency": "EUR", 103 | "data": null, 104 | "limitedToHostCollectiveIds": [11004], 105 | "__typename": "PaymentMethodType" 106 | }, 107 | { 108 | "id": null, 109 | "name": "€5 Gift Card from BrusselsTogether ASBL", 110 | "uuid": "411059e1-1679-4cbc-a9f0-9e40bc6add2b", 111 | "description": "€5 Gift Card from BrusselsTogether ASBL", 112 | "initialBalance": 2500, 113 | "monthlyLimitPerMember": null, 114 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 115 | "currency": "EUR", 116 | "data": null, 117 | "limitedToHostCollectiveIds": [11004], 118 | "__typename": "PaymentMethodType" 119 | }, 120 | { 121 | "id": null, 122 | "name": "€5 Gift Card from BrusselsTogether ASBL", 123 | "uuid": "c7d8f2af-4425-4f42-a485-05b46e91d065", 124 | "description": "€5 Gift Card from BrusselsTogether ASBL", 125 | "initialBalance": 2500, 126 | "monthlyLimitPerMember": null, 127 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 128 | "currency": "EUR", 129 | "data": null, 130 | "limitedToHostCollectiveIds": [11004], 131 | "__typename": "PaymentMethodType" 132 | }, 133 | { 134 | "id": null, 135 | "name": "€5 Gift Card from BrusselsTogether ASBL", 136 | "uuid": "781c018a-0610-47b1-b6b8-7a0c9188d048", 137 | "description": "€5 Gift Card from BrusselsTogether ASBL", 138 | "initialBalance": 2500, 139 | "monthlyLimitPerMember": null, 140 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 141 | "currency": "EUR", 142 | "data": null, 143 | "limitedToHostCollectiveIds": [11004], 144 | "__typename": "PaymentMethodType" 145 | }, 146 | { 147 | "id": null, 148 | "name": "€5 Gift Card from BrusselsTogether ASBL", 149 | "uuid": "4dd728f2-040e-4fd4-a6e0-cbfb02bec89d", 150 | "description": "€5 Gift Card from BrusselsTogether ASBL", 151 | "initialBalance": 2500, 152 | "monthlyLimitPerMember": null, 153 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 154 | "currency": "EUR", 155 | "data": null, 156 | "limitedToHostCollectiveIds": [11004], 157 | "__typename": "PaymentMethodType" 158 | }, 159 | { 160 | "id": null, 161 | "name": "€5 Gift Card from BrusselsTogether ASBL", 162 | "uuid": "a06c9dc9-512c-4fa1-b4ae-9bc9a40ea813", 163 | "description": "€5 Gift Card from BrusselsTogether ASBL", 164 | "initialBalance": 2500, 165 | "monthlyLimitPerMember": null, 166 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 167 | "currency": "EUR", 168 | "data": null, 169 | "limitedToHostCollectiveIds": [11004], 170 | "__typename": "PaymentMethodType" 171 | }, 172 | { 173 | "id": null, 174 | "name": "€5 Gift Card from BrusselsTogether ASBL", 175 | "uuid": "e040ce66-323f-4afe-be2a-6f97a4d3fc68", 176 | "description": "€5 Gift Card from BrusselsTogether ASBL", 177 | "initialBalance": 2500, 178 | "monthlyLimitPerMember": null, 179 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 180 | "currency": "EUR", 181 | "data": null, 182 | "limitedToHostCollectiveIds": [11004], 183 | "__typename": "PaymentMethodType" 184 | }, 185 | { 186 | "id": null, 187 | "name": "€5 Gift Card from BrusselsTogether ASBL", 188 | "uuid": "74849fa4-f5ed-43a5-81e8-55451980430c", 189 | "description": "€5 Gift Card from BrusselsTogether ASBL", 190 | "initialBalance": 2500, 191 | "monthlyLimitPerMember": null, 192 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 193 | "currency": "EUR", 194 | "data": null, 195 | "limitedToHostCollectiveIds": [11004], 196 | "__typename": "PaymentMethodType" 197 | }, 198 | { 199 | "id": null, 200 | "name": "€5 Gift Card from BrusselsTogether ASBL", 201 | "uuid": "0148b9e3-7570-46e2-a898-5ae92ae0e219", 202 | "description": "€5 Gift Card from BrusselsTogether ASBL", 203 | "initialBalance": 2500, 204 | "monthlyLimitPerMember": null, 205 | "expiryDate": "Fri May 15 2020 00:00:00 GMT+0200 (GMT+02:00)", 206 | "currency": "EUR", 207 | "data": null, 208 | "limitedToHostCollectiveIds": [11004], 209 | "__typename": "PaymentMethodType" 210 | } 211 | ] 212 | } 213 | -------------------------------------------------------------------------------- /test/components/__snapshots__/PrintableGiftCard.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | .c1 { 5 | box-sizing: border-box; 6 | padding-top: 1em; 7 | padding-left: 0.8em; 8 | padding-right: 0.8em; 9 | } 10 | 11 | .c9 { 12 | box-sizing: border-box; 13 | } 14 | 15 | .c23 { 16 | box-sizing: border-box; 17 | margin-left: 0.25em; 18 | } 19 | 20 | .c2 { 21 | box-sizing: border-box; 22 | display: -webkit-box; 23 | display: -webkit-flex; 24 | display: -ms-flexbox; 25 | display: flex; 26 | -webkit-align-items: center; 27 | -webkit-box-align: center; 28 | -ms-flex-align: center; 29 | align-items: center; 30 | -webkit-box-pack: justify; 31 | -webkit-justify-content: space-between; 32 | -ms-flex-pack: justify; 33 | justify-content: space-between; 34 | } 35 | 36 | .c3 { 37 | box-sizing: border-box; 38 | display: -webkit-box; 39 | display: -webkit-flex; 40 | display: -ms-flexbox; 41 | display: flex; 42 | -webkit-align-items: center; 43 | -webkit-box-align: center; 44 | -ms-flex-align: center; 45 | align-items: center; 46 | } 47 | 48 | .c5 { 49 | box-sizing: border-box; 50 | margin-left: 0.8em; 51 | display: -webkit-box; 52 | display: -webkit-flex; 53 | display: -ms-flexbox; 54 | display: flex; 55 | -webkit-flex-direction: column; 56 | -ms-flex-direction: column; 57 | flex-direction: column; 58 | } 59 | 60 | .c12 { 61 | box-sizing: border-box; 62 | margin-bottom: 0.6em; 63 | -webkit-flex: 1 1; 64 | -ms-flex: 1 1; 65 | flex: 1 1; 66 | display: -webkit-box; 67 | display: -webkit-flex; 68 | display: -ms-flexbox; 69 | display: flex; 70 | -webkit-align-items: flex-end; 71 | -webkit-box-align: flex-end; 72 | -ms-flex-align: flex-end; 73 | align-items: flex-end; 74 | -webkit-box-pack: justify; 75 | -webkit-justify-content: space-between; 76 | -ms-flex-pack: justify; 77 | justify-content: space-between; 78 | } 79 | 80 | .c13 { 81 | box-sizing: border-box; 82 | margin-bottom: 0.25em; 83 | margin-left: 0.8em; 84 | display: -webkit-box; 85 | display: -webkit-flex; 86 | display: -ms-flexbox; 87 | display: flex; 88 | -webkit-flex-direction: column; 89 | -ms-flex-direction: column; 90 | flex-direction: column; 91 | -webkit-box-pack: justify; 92 | -webkit-justify-content: space-between; 93 | -ms-flex-pack: justify; 94 | justify-content: space-between; 95 | } 96 | 97 | .c18 { 98 | box-sizing: border-box; 99 | padding-right: 0.8em; 100 | display: -webkit-box; 101 | display: -webkit-flex; 102 | display: -ms-flexbox; 103 | display: flex; 104 | -webkit-flex-direction: column; 105 | -ms-flex-direction: column; 106 | flex-direction: column; 107 | -webkit-align-items: flex-end; 108 | -webkit-box-align: flex-end; 109 | -ms-flex-align: flex-end; 110 | align-items: flex-end; 111 | -webkit-box-pack: end; 112 | -webkit-justify-content: flex-end; 113 | -ms-flex-pack: end; 114 | justify-content: flex-end; 115 | } 116 | 117 | .c19 { 118 | box-sizing: border-box; 119 | display: -webkit-box; 120 | display: -webkit-flex; 121 | display: -ms-flexbox; 122 | display: flex; 123 | -webkit-flex-direction: column; 124 | -ms-flex-direction: column; 125 | flex-direction: column; 126 | -webkit-align-items: flex-end; 127 | -webkit-box-align: flex-end; 128 | -ms-flex-align: flex-end; 129 | align-items: flex-end; 130 | -webkit-box-pack: end; 131 | -webkit-justify-content: flex-end; 132 | -ms-flex-pack: end; 133 | justify-content: flex-end; 134 | } 135 | 136 | .c20 { 137 | box-sizing: border-box; 138 | margin-top: 1em; 139 | margin-bottom: 0.4em; 140 | display: -webkit-box; 141 | display: -webkit-flex; 142 | display: -ms-flexbox; 143 | display: flex; 144 | } 145 | 146 | .c15 { 147 | display: inline-block; 148 | vertical-align: middle; 149 | overflow: hidden; 150 | } 151 | 152 | .c8 { 153 | box-sizing: border-box; 154 | background: #69a0f1; 155 | border-radius: 1em; 156 | border-radius: 1em; 157 | box-shadow: 2px 3px 5px rgba(0,0,0,0.15); 158 | color: #d7e8fe; 159 | font-weight: bold; 160 | font-size: 0.7em; 161 | padding: 0.25em 1em; 162 | } 163 | 164 | .c10 { 165 | border: 0; 166 | border-top: 1px solid; 167 | margin: 0; 168 | height: 1px; 169 | margin-top: 0.75em; 170 | margin-bottom: 0.5em; 171 | border-color: rgb(73,139,237); 172 | } 173 | 174 | .c6 { 175 | font-size: 1.1em; 176 | font-weight: bold; 177 | line-height: 1.5em; 178 | -webkit-letter-spacing: -0.2px; 179 | -moz-letter-spacing: -0.2px; 180 | -ms-letter-spacing: -0.2px; 181 | letter-spacing: -0.2px; 182 | margin: 0; 183 | } 184 | 185 | .c7 { 186 | color: white.transparent.72; 187 | font-size: 0.7em; 188 | line-height: 0.8em; 189 | -webkit-letter-spacing: -0.2px; 190 | -moz-letter-spacing: -0.2px; 191 | -ms-letter-spacing: -0.2px; 192 | letter-spacing: -0.2px; 193 | margin: 0; 194 | } 195 | 196 | .c11 { 197 | color: black.300; 198 | font-size: 0.55em; 199 | line-height: 1.75em; 200 | -webkit-letter-spacing: -0.2px; 201 | -moz-letter-spacing: -0.2px; 202 | -ms-letter-spacing: -0.2px; 203 | letter-spacing: -0.2px; 204 | margin: 0; 205 | } 206 | 207 | .c14 { 208 | font-size: 0.75em; 209 | line-height: Paragraph; 210 | -webkit-letter-spacing: -0.2px; 211 | -moz-letter-spacing: -0.2px; 212 | -ms-letter-spacing: -0.2px; 213 | letter-spacing: -0.2px; 214 | margin: 0; 215 | } 216 | 217 | .c16 { 218 | color: black.500; 219 | font-size: inherit; 220 | line-height: inherit; 221 | -webkit-letter-spacing: -0.2px; 222 | -moz-letter-spacing: -0.2px; 223 | -ms-letter-spacing: -0.2px; 224 | letter-spacing: -0.2px; 225 | margin: 0; 226 | margin-left: 4px; 227 | } 228 | 229 | .c17 { 230 | color: black.800; 231 | font-size: inherit; 232 | font-weight: bold; 233 | line-height: inherit; 234 | -webkit-letter-spacing: -0.2px; 235 | -moz-letter-spacing: -0.2px; 236 | -ms-letter-spacing: -0.2px; 237 | letter-spacing: -0.2px; 238 | margin: 0; 239 | } 240 | 241 | .c21 { 242 | color: #313233; 243 | font-size: 1.9em; 244 | font-weight: bold; 245 | line-height: 1em; 246 | -webkit-letter-spacing: -0.2px; 247 | -moz-letter-spacing: -0.2px; 248 | -ms-letter-spacing: -0.2px; 249 | letter-spacing: -0.2px; 250 | margin: 0; 251 | } 252 | 253 | .c22 { 254 | font-size: inherit; 255 | line-height: inherit; 256 | -webkit-letter-spacing: -0.2px; 257 | -moz-letter-spacing: -0.2px; 258 | -ms-letter-spacing: -0.2px; 259 | letter-spacing: -0.2px; 260 | margin: 0; 261 | } 262 | 263 | .c24 { 264 | color: black.700; 265 | font-size: 0.7em; 266 | line-height: inherit; 267 | -webkit-letter-spacing: -0.2px; 268 | -moz-letter-spacing: -0.2px; 269 | -ms-letter-spacing: -0.2px; 270 | letter-spacing: -0.2px; 271 | margin: 0; 272 | } 273 | 274 | .c0 { 275 | box-sizing: border-box; 276 | font-size: 0.14in; 277 | font-family: Helvetica,sans-serif; 278 | width: 85.60mm; 279 | height: 53.98mm; 280 | position: relative; 281 | overflow: hidden; 282 | background-image: url('http://localhost:3004/static/images/oc-gift-card-front-straightened.png'); 283 | background-size: 100%; 284 | background-repeat: no-repeat; 285 | color: white; 286 | display: -webkit-box; 287 | display: -webkit-flex; 288 | display: -ms-flexbox; 289 | display: flex; 290 | -webkit-flex-direction: column; 291 | -ms-flex-direction: column; 292 | flex-direction: column; 293 | -webkit-box-pack: justify; 294 | -webkit-justify-content: space-between; 295 | -ms-flex-pack: justify; 296 | justify-content: space-between; 297 | outline: 0.1em dashed rgba(62,130,230,0.15); 298 | margin: 2em; 299 | border-radius: 0px; 300 | font-size: 0.14in; 301 | } 302 | 303 | .c4 { 304 | width: 3em; 305 | height: 3em; 306 | } 307 | 308 | @media print { 309 | .c0 { 310 | -webkit-break-inside: avoid; 311 | break-inside: avoid; 312 | } 313 | } 314 | 315 |
324 |
327 |
330 |
333 | 338 |
341 |

344 | Open Collective 345 |

346 |

349 | I have no QR code! 350 |

351 |
352 |
353 |
357 | 358 | Gift card 359 | 360 |
361 |
362 |
365 |
368 |

371 | 372 | Expires on 373 | 374 | May 12, 2020 375 | 376 | 377 |

378 |
379 |
380 |
383 |
386 |

389 | 415 | 418 | opencollective.com/redeem/ 419 | 420 | 423 | 8X4WWD2G 424 | 425 |

426 |
427 |
430 |
433 |
436 | 439 | 442 | $500 443 | 444 | 445 |
448 | 451 | USD 452 | 453 |
454 |
455 |
456 |
457 |
458 |
459 | `; 460 | --------------------------------------------------------------------------------