├── .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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 | [](https://circleci.com/gh/opencollective/opencollective-giftcards-generator/tree/master)
8 | [](https://slack.opencollective.org)
9 | [](https://david-dm.org/opencollective/opencollective-giftcards-generator)
10 | [](https://greenkeeper.io/)
11 | [](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 |
--------------------------------------------------------------------------------