mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-27 03:39:11 +00:00
Compare commits
9 Commits
@vercel/cl
...
@vercel/hy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1edc2d06c9 | ||
|
|
fdb15b2539 | ||
|
|
32ebcd83a7 | ||
|
|
2e43b2b88a | ||
|
|
f83d432fcd | ||
|
|
87fc38e860 | ||
|
|
afc4388fc0 | ||
|
|
3c48b40b43 | ||
|
|
ce89f00328 |
@@ -19,6 +19,9 @@ packages/cli/src/util/dev/templates/*.ts
|
||||
packages/client/tests/fixtures
|
||||
packages/client/lib
|
||||
|
||||
# hydrogen
|
||||
packages/hydrogen/edge-entry.js
|
||||
|
||||
# next
|
||||
packages/next/test/integration/middleware
|
||||
packages/next/test/integration/middleware-eval
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/build-utils",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1-canary.0",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vercel",
|
||||
"version": "26.0.0",
|
||||
"version": "26.0.1-canary.1",
|
||||
"preferGlobal": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "The command-line interface for Vercel",
|
||||
@@ -42,15 +42,15 @@
|
||||
"node": ">= 14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/build-utils": "5.0.0",
|
||||
"@vercel/go": "2.0.4",
|
||||
"@vercel/next": "3.1.3",
|
||||
"@vercel/node": "2.4.0",
|
||||
"@vercel/python": "3.0.4",
|
||||
"@vercel/redwood": "1.0.5",
|
||||
"@vercel/remix": "1.0.5",
|
||||
"@vercel/ruby": "1.3.12",
|
||||
"@vercel/static-build": "1.0.4",
|
||||
"@vercel/build-utils": "5.0.1-canary.0",
|
||||
"@vercel/go": "2.0.5-canary.0",
|
||||
"@vercel/next": "3.1.4-canary.1",
|
||||
"@vercel/node": "2.4.1-canary.0",
|
||||
"@vercel/python": "3.0.5-canary.0",
|
||||
"@vercel/redwood": "1.0.6-canary.0",
|
||||
"@vercel/remix": "1.0.6-canary.0",
|
||||
"@vercel/ruby": "1.3.13-canary.0",
|
||||
"@vercel/static-build": "1.0.5-canary.0",
|
||||
"update-notifier": "5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -95,7 +95,7 @@
|
||||
"@types/which": "1.3.2",
|
||||
"@types/write-json-file": "2.2.1",
|
||||
"@types/yauzl-promise": "2.1.0",
|
||||
"@vercel/client": "12.0.4",
|
||||
"@vercel/client": "12.0.5-canary.0",
|
||||
"@vercel/frameworks": "1.0.2",
|
||||
"@vercel/fs-detectors": "1.0.0",
|
||||
"@vercel/ncc": "0.24.0",
|
||||
@@ -114,7 +114,6 @@
|
||||
"chalk": "4.1.0",
|
||||
"chance": "1.1.7",
|
||||
"chokidar": "3.3.1",
|
||||
"clipboardy": "2.1.0",
|
||||
"codecov": "3.8.2",
|
||||
"cpy": "7.2.0",
|
||||
"credit-card": "3.0.1",
|
||||
|
||||
@@ -68,7 +68,6 @@ export const help = () => `
|
||||
-m, --meta Add metadata for the deployment (e.g.: ${chalk.dim(
|
||||
'`-m KEY=value`'
|
||||
)}). Can appear many times.
|
||||
-C, --no-clipboard Do not attempt to copy URL to clipboard
|
||||
-S, --scope Set a custom scope
|
||||
--regions Set default regions to enable the deployment on
|
||||
--prod Create a production deployment
|
||||
|
||||
@@ -10,7 +10,6 @@ import { readLocalConfig } from '../../util/config/files';
|
||||
import getArgs from '../../util/get-args';
|
||||
import { handleError } from '../../util/error';
|
||||
import Client from '../../util/client';
|
||||
import { write as copy } from 'clipboardy';
|
||||
import { getPrettyError } from '@vercel/build-utils';
|
||||
import toHumanPath from '../../util/humanize-path';
|
||||
import Now from '../../util';
|
||||
@@ -65,7 +64,7 @@ import { help } from './args';
|
||||
import { getDeploymentChecks } from '../../util/deploy/get-deployment-checks';
|
||||
import parseTarget from '../../util/deploy/parse-target';
|
||||
import getPrebuiltJson from '../../util/deploy/get-prebuilt-json';
|
||||
import { createGitMeta } from '../../util/deploy/create-git-meta';
|
||||
import { createGitMeta } from '../../util/create-git-meta';
|
||||
|
||||
export default async (client: Client) => {
|
||||
const { output } = client;
|
||||
@@ -77,7 +76,6 @@ export default async (client: Client) => {
|
||||
'--force': Boolean,
|
||||
'--with-cache': Boolean,
|
||||
'--public': Boolean,
|
||||
'--no-clipboard': Boolean,
|
||||
'--env': [String],
|
||||
'--build-env': [String],
|
||||
'--meta': [String],
|
||||
@@ -91,7 +89,6 @@ export default async (client: Client) => {
|
||||
'-p': '--public',
|
||||
'-e': '--env',
|
||||
'-b': '--build-env',
|
||||
'-C': '--no-clipboard',
|
||||
'-m': '--meta',
|
||||
'-c': '--confirm',
|
||||
|
||||
@@ -686,13 +683,7 @@ export default async (client: Client) => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return printDeploymentStatus(
|
||||
output,
|
||||
client,
|
||||
deployment,
|
||||
deployStamp,
|
||||
!argv['--no-clipboard']
|
||||
);
|
||||
return printDeploymentStatus(output, client, deployment, deployStamp);
|
||||
};
|
||||
|
||||
function handleCreateDeployError(
|
||||
@@ -825,8 +816,7 @@ const printDeploymentStatus = async (
|
||||
action?: string;
|
||||
};
|
||||
},
|
||||
deployStamp: () => string,
|
||||
isClipboardEnabled: boolean
|
||||
deployStamp: () => string
|
||||
) => {
|
||||
indications = indications || [];
|
||||
const isProdDeployment = target === 'production';
|
||||
@@ -847,40 +837,23 @@ const printDeploymentStatus = async (
|
||||
} else {
|
||||
// print preview/production url
|
||||
let previewUrl: string;
|
||||
let isWildcard: boolean;
|
||||
if (Array.isArray(aliasList) && aliasList.length > 0) {
|
||||
const previewUrlInfo = await getPreferredPreviewURL(client, aliasList);
|
||||
if (previewUrlInfo) {
|
||||
isWildcard = previewUrlInfo.isWildcard;
|
||||
previewUrl = previewUrlInfo.previewUrl;
|
||||
} else {
|
||||
isWildcard = false;
|
||||
previewUrl = `https://${deploymentUrl}`;
|
||||
}
|
||||
} else {
|
||||
// fallback to deployment url
|
||||
isWildcard = false;
|
||||
previewUrl = `https://${deploymentUrl}`;
|
||||
}
|
||||
|
||||
// copy to clipboard
|
||||
let isCopiedToClipboard = false;
|
||||
if (isClipboardEnabled && !isWildcard) {
|
||||
try {
|
||||
await copy(previewUrl);
|
||||
isCopiedToClipboard = true;
|
||||
} catch (err) {
|
||||
output.debug(`Error copyind to clipboard: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
output.print(
|
||||
prependEmoji(
|
||||
`${isProdDeployment ? 'Production' : 'Preview'}: ${chalk.bold(
|
||||
previewUrl
|
||||
)}${
|
||||
isCopiedToClipboard ? chalk.gray(` [copied to clipboard]`) : ''
|
||||
} ${deployStamp()}`,
|
||||
)} ${deployStamp()}`,
|
||||
emoji('success')
|
||||
) + `\n`
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import cmd from '../util/output/cmd';
|
||||
import logo from '../util/output/logo';
|
||||
import elapsed from '../util/output/elapsed';
|
||||
import strlen from '../util/strlen';
|
||||
import getScope from '../util/get-scope';
|
||||
import toHost from '../util/to-host';
|
||||
import parseMeta from '../util/parse-meta';
|
||||
import { isValidName } from '../util/is-valid-name';
|
||||
@@ -16,6 +15,10 @@ import getCommandFlags from '../util/get-command-flags';
|
||||
import { getPkgName, getCommandName } from '../util/pkg-name';
|
||||
import Client from '../util/client';
|
||||
import { Deployment } from '../types';
|
||||
import validatePaths from '../util/validate-paths';
|
||||
import { getLinkedProject } from '../util/projects/link';
|
||||
import { ensureLink } from '../util/ensure-link';
|
||||
import getScope from '../util/get-scope';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
@@ -31,6 +34,7 @@ const help = () => {
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--confirm Skip the confirmation prompt
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
@@ -42,12 +46,14 @@ const help = () => {
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} List all deployments
|
||||
${chalk.gray('–')} List all deployments for the currently linked project
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} ls`)}
|
||||
|
||||
${chalk.gray('–')} List all deployments for the app ${chalk.dim('`my-app`')}
|
||||
|
||||
${chalk.gray('–')} List all deployments for the project ${chalk.dim(
|
||||
'`my-app`'
|
||||
)} in the team of the currently linked project
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} ls my-app`)}
|
||||
|
||||
${chalk.gray('–')} Filter deployments by metadata
|
||||
@@ -71,6 +77,7 @@ export default async function main(client: Client) {
|
||||
'-m': '--meta',
|
||||
'--next': Number,
|
||||
'-N': '--next',
|
||||
'--confirm': Boolean,
|
||||
});
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
@@ -86,18 +93,64 @@ export default async function main(client: Client) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let app: string | undefined = argv._[1];
|
||||
let host: string | undefined = undefined;
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
return 2;
|
||||
}
|
||||
|
||||
const meta = parseMeta(argv['--meta']);
|
||||
const { currentTeam, includeScheme } = config;
|
||||
const yes = argv['--confirm'] || false;
|
||||
|
||||
let contextName = null;
|
||||
const meta = parseMeta(argv['--meta']);
|
||||
const { includeScheme } = config;
|
||||
|
||||
let paths = [process.cwd()];
|
||||
const pathValidation = await validatePaths(client, paths);
|
||||
if (!pathValidation.valid) {
|
||||
return pathValidation.exitCode;
|
||||
}
|
||||
|
||||
const { path } = pathValidation;
|
||||
|
||||
// retrieve `project` and `org` from .vercel
|
||||
let link = await getLinkedProject(client, path);
|
||||
|
||||
if (link.status === 'error') {
|
||||
return link.exitCode;
|
||||
}
|
||||
|
||||
let { org, project, status } = link;
|
||||
const appArg: string | undefined = argv._[1];
|
||||
let app: string | undefined = appArg || project?.name;
|
||||
let host: string | undefined = undefined;
|
||||
|
||||
if (app && !isValidName(app)) {
|
||||
error(`The provided argument "${app}" is not a valid project name`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If there's no linked project and user doesn't pass `app` arg,
|
||||
// prompt to link their current directory.
|
||||
if (status === 'not_linked' && !app) {
|
||||
const linkedProject = await ensureLink('list', client, path, yes);
|
||||
if (typeof linkedProject === 'number') {
|
||||
return linkedProject;
|
||||
}
|
||||
link.org = linkedProject.org;
|
||||
link.project = linkedProject.project;
|
||||
}
|
||||
|
||||
let { contextName, team } = await getScope(client);
|
||||
|
||||
// If user passed in a custom scope, update the current team & context name
|
||||
if (argv['--scope']) {
|
||||
client.config.currentTeam = team?.id || undefined;
|
||||
if (team?.slug) contextName = team.slug;
|
||||
} else {
|
||||
client.config.currentTeam = org?.type === 'team' ? org.id : undefined;
|
||||
if (org?.slug) contextName = org.slug;
|
||||
}
|
||||
|
||||
const { currentTeam } = config;
|
||||
|
||||
try {
|
||||
({ contextName } = await getScope(client));
|
||||
@@ -152,6 +205,7 @@ export default async function main(client: Client) {
|
||||
}
|
||||
|
||||
debug('Fetching deployments');
|
||||
|
||||
const response = await now.list(app, {
|
||||
version: 6,
|
||||
meta,
|
||||
@@ -194,17 +248,18 @@ export default async function main(client: Client) {
|
||||
deployments = deployments.filter(deployment => deployment.url === host);
|
||||
}
|
||||
|
||||
// we don't output the table headers if we have no deployments
|
||||
if (!deployments.length) {
|
||||
log(`No deployments found.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
log(
|
||||
`Deployments under ${chalk.bold(contextName)} ${elapsed(
|
||||
Date.now() - start
|
||||
)}`
|
||||
);
|
||||
|
||||
// we don't output the table headers if we have no deployments
|
||||
if (!deployments.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// information to help the user find other deployments or instances
|
||||
if (app == null) {
|
||||
log(
|
||||
@@ -216,7 +271,7 @@ export default async function main(client: Client) {
|
||||
|
||||
print('\n');
|
||||
|
||||
console.log(
|
||||
client.output.print(
|
||||
`${table(
|
||||
[
|
||||
['project', 'latest deployment', 'state', 'age', 'username'].map(
|
||||
@@ -247,7 +302,7 @@ export default async function main(client: Client) {
|
||||
hsep: ' '.repeat(4),
|
||||
stringLength: strlen,
|
||||
}
|
||||
).replace(/^/gm, ' ')}\n`
|
||||
).replace(/^/gm, ' ')}\n\n`
|
||||
);
|
||||
|
||||
if (pagination && pagination.count === 20) {
|
||||
@@ -270,7 +325,7 @@ function getProjectName(d: Deployment) {
|
||||
}
|
||||
|
||||
// renders the state string
|
||||
function stateString(s: string) {
|
||||
export function stateString(s: string) {
|
||||
switch (s) {
|
||||
case 'INITIALIZING':
|
||||
return chalk.yellow(s);
|
||||
|
||||
@@ -10,6 +10,20 @@ import getScope from '../util/get-scope';
|
||||
import getCommandFlags from '../util/get-command-flags';
|
||||
import { getPkgName, getCommandName } from '../util/pkg-name';
|
||||
import Client from '../util/client';
|
||||
import validatePaths from '../util/validate-paths';
|
||||
import { ensureLink } from '../util/ensure-link';
|
||||
import { parseGitConfig, pluckRemoteUrl } from '../util/create-git-meta';
|
||||
import {
|
||||
connectGitProvider,
|
||||
disconnectGitProvider,
|
||||
formatProvider,
|
||||
parseRepoUrl,
|
||||
} from '../util/projects/connect-git-provider';
|
||||
import { join } from 'path';
|
||||
import { Team, User } from '../types';
|
||||
import confirm from '../util/input/confirm';
|
||||
import { Output } from '../util/output';
|
||||
import link from '../util/output/link';
|
||||
|
||||
const e = encodeURIComponent;
|
||||
|
||||
@@ -20,6 +34,7 @@ const help = () => {
|
||||
${chalk.dim('Commands:')}
|
||||
|
||||
ls Show all projects in the selected team/user
|
||||
connect Connect a Git provider to your project
|
||||
add [name] Add a new project
|
||||
rm [name] Remove a project
|
||||
|
||||
@@ -54,6 +69,7 @@ const main = async (client: Client) => {
|
||||
argv = getArgs(client.argv.slice(2), {
|
||||
'--next': Number,
|
||||
'-N': '--next',
|
||||
'--yes': Boolean,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
@@ -71,10 +87,10 @@ const main = async (client: Client) => {
|
||||
|
||||
const { output } = client;
|
||||
|
||||
let contextName = null;
|
||||
let scope = null;
|
||||
|
||||
try {
|
||||
({ contextName } = await getScope(client));
|
||||
scope = await getScope(client);
|
||||
} catch (err) {
|
||||
if (err.code === 'NOT_AUTHORIZED' || err.code === 'TEAM_DELETED') {
|
||||
output.error(err.message);
|
||||
@@ -84,17 +100,12 @@ const main = async (client: Client) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await run({ client, contextName });
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
exit(1);
|
||||
}
|
||||
return await run({ client, scope });
|
||||
};
|
||||
|
||||
export default async (client: Client) => {
|
||||
try {
|
||||
await main(client);
|
||||
return await main(client);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
process.exit(1);
|
||||
@@ -103,16 +114,148 @@ export default async (client: Client) => {
|
||||
|
||||
async function run({
|
||||
client,
|
||||
contextName,
|
||||
scope,
|
||||
}: {
|
||||
client: Client;
|
||||
contextName: string;
|
||||
scope: {
|
||||
contextName: string;
|
||||
team: Team | null;
|
||||
user: User;
|
||||
};
|
||||
}) {
|
||||
const { output } = client;
|
||||
const { contextName, team } = scope;
|
||||
const args = argv._.slice(1);
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
if (subcommand === 'connect') {
|
||||
const yes = Boolean(argv['--yes']);
|
||||
if (args.length !== 0) {
|
||||
output.error(
|
||||
`Invalid number of arguments. Usage: ${chalk.cyan(
|
||||
`${getCommandName('project connect')}`
|
||||
)}`
|
||||
);
|
||||
return exit(2);
|
||||
}
|
||||
|
||||
let paths = [process.cwd()];
|
||||
|
||||
const validate = await validatePaths(client, paths);
|
||||
if (!validate.valid) {
|
||||
return validate.exitCode;
|
||||
}
|
||||
const { path } = validate;
|
||||
|
||||
const linkedProject = await ensureLink(
|
||||
'project connect',
|
||||
client,
|
||||
path,
|
||||
yes
|
||||
);
|
||||
if (typeof linkedProject === 'number') {
|
||||
return linkedProject;
|
||||
}
|
||||
|
||||
const { project, org } = linkedProject;
|
||||
const gitProviderLink = project.link;
|
||||
|
||||
client.config.currentTeam = org.type === 'team' ? org.id : undefined;
|
||||
|
||||
// get project from .git
|
||||
const gitConfigPath = join(path, '.git/config');
|
||||
const gitConfig = await parseGitConfig(gitConfigPath, output);
|
||||
if (!gitConfig) {
|
||||
output.error(
|
||||
`No local git repo found. Run ${chalk.cyan(
|
||||
'`git clone <url>`'
|
||||
)} to clone a remote Git repository first.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const remoteUrl = pluckRemoteUrl(gitConfig);
|
||||
if (!remoteUrl) {
|
||||
output.error(
|
||||
`No remote origin URL found in your Git config. Make sure you've connected your local Git repo to a Git provider first.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const parsedUrl = parseRepoUrl(remoteUrl);
|
||||
if (!parsedUrl) {
|
||||
output.error(
|
||||
`Failed to parse Git repo data from the following remote URL in your Git config: ${link(
|
||||
remoteUrl
|
||||
)}`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const { provider, org: gitOrg, repo } = parsedUrl;
|
||||
const repoPath = `${gitOrg}/${repo}`;
|
||||
let connectedRepoPath;
|
||||
|
||||
if (!gitProviderLink) {
|
||||
const connect = await connectGitProvider(
|
||||
client,
|
||||
team,
|
||||
project.id,
|
||||
provider,
|
||||
repoPath
|
||||
);
|
||||
if (typeof connect === 'number') {
|
||||
return connect;
|
||||
}
|
||||
} else {
|
||||
const connectedProvider = gitProviderLink.type;
|
||||
const connectedOrg = gitProviderLink.org;
|
||||
const connectedRepo = gitProviderLink.repo;
|
||||
connectedRepoPath = `${connectedOrg}/${connectedRepo}`;
|
||||
|
||||
const isSameRepo =
|
||||
connectedProvider === provider &&
|
||||
connectedOrg === gitOrg &&
|
||||
connectedRepo === repo;
|
||||
if (isSameRepo) {
|
||||
output.log(
|
||||
`${chalk.cyan(
|
||||
connectedRepoPath
|
||||
)} is already connected to your project.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const shouldReplaceRepo = await confirmRepoConnect(
|
||||
client,
|
||||
output,
|
||||
yes,
|
||||
connectedRepoPath
|
||||
);
|
||||
if (!shouldReplaceRepo) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await disconnectGitProvider(client, team, project.id);
|
||||
const connect = await connectGitProvider(
|
||||
client,
|
||||
team,
|
||||
project.id,
|
||||
provider,
|
||||
repoPath
|
||||
);
|
||||
if (typeof connect === 'number') {
|
||||
return connect;
|
||||
}
|
||||
}
|
||||
|
||||
output.log(
|
||||
`Connected ${formatProvider(provider)} repository ${chalk.cyan(
|
||||
repoPath
|
||||
)}!`
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (subcommand === 'ls' || subcommand === 'list') {
|
||||
if (args.length !== 0) {
|
||||
console.error(
|
||||
@@ -271,7 +414,7 @@ async function run({
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error('Please specify a valid subcommand: ls | add | rm'));
|
||||
output.error('Please specify a valid subcommand: ls | connect | add | rm');
|
||||
help();
|
||||
exit(2);
|
||||
}
|
||||
@@ -281,6 +424,28 @@ process.on('uncaughtException', err => {
|
||||
exit(1);
|
||||
});
|
||||
|
||||
async function confirmRepoConnect(
|
||||
client: Client,
|
||||
output: Output,
|
||||
yes: boolean,
|
||||
connectedRepoPath: string
|
||||
) {
|
||||
let shouldReplaceProject = yes;
|
||||
if (!shouldReplaceProject) {
|
||||
shouldReplaceProject = await confirm(
|
||||
client,
|
||||
`Looks like you already have a repository connected: ${chalk.cyan(
|
||||
connectedRepoPath
|
||||
)}. Do you want to replace it?`,
|
||||
true
|
||||
);
|
||||
if (!shouldReplaceProject) {
|
||||
output.log(`Aborted. Repo not connected.`);
|
||||
}
|
||||
}
|
||||
return shouldReplaceProject;
|
||||
}
|
||||
|
||||
function readConfirmation(projectName: string) {
|
||||
return new Promise(resolve => {
|
||||
process.stdout.write(
|
||||
|
||||
@@ -130,6 +130,8 @@ export type Deployment = {
|
||||
version?: number;
|
||||
created: number;
|
||||
createdAt: number;
|
||||
ready?: number;
|
||||
buildingAt?: number;
|
||||
creator: { uid: string; username: string };
|
||||
target: string | null;
|
||||
ownerId: string;
|
||||
@@ -246,12 +248,34 @@ export interface ProjectEnvVariable {
|
||||
gitBranch?: string;
|
||||
}
|
||||
|
||||
export interface DeployHook {
|
||||
createdAt: number;
|
||||
id: string;
|
||||
name: string;
|
||||
ref: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProjectLinkData {
|
||||
type: string;
|
||||
repo: string;
|
||||
repoId: number;
|
||||
org?: string;
|
||||
gitCredentialId: string;
|
||||
productionBranch?: string | null;
|
||||
sourceless: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
deployHooks?: DeployHook[];
|
||||
}
|
||||
|
||||
export interface Project extends ProjectSettings {
|
||||
id: string;
|
||||
name: string;
|
||||
accountId: string;
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
link?: ProjectLinkData;
|
||||
alias?: ProjectAliasTarget[];
|
||||
latestDeployments?: Partial<Deployment>[];
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { join } from 'path';
|
||||
import ini from 'ini';
|
||||
import git from 'git-last-commit';
|
||||
import { exec } from 'child_process';
|
||||
import { GitMetadata } from '../../types';
|
||||
import { Output } from '../output';
|
||||
import { GitMetadata } from '../types';
|
||||
import { Output } from './output';
|
||||
|
||||
export function isDirty(directory: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -33,21 +33,31 @@ function getLastCommit(directory: string): Promise<git.Commit> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseGitConfig(configPath: string, output: Output) {
|
||||
try {
|
||||
return ini.parse(await fs.readFile(configPath, 'utf-8'));
|
||||
} catch (error) {
|
||||
output.debug(`Error while parsing repo data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluckRemoteUrl(gitConfig: {
|
||||
[key: string]: any;
|
||||
}): string | undefined {
|
||||
// Assuming "origin" is the remote url that the user would want to use
|
||||
return gitConfig['remote "origin"']?.url;
|
||||
}
|
||||
|
||||
export async function getRemoteUrl(
|
||||
configPath: string,
|
||||
output: Output
|
||||
): Promise<string | null> {
|
||||
let gitConfig;
|
||||
try {
|
||||
gitConfig = ini.parse(await fs.readFile(configPath, 'utf-8'));
|
||||
} catch (error) {
|
||||
output.debug(`Error while parsing repo data: ${error.message}`);
|
||||
}
|
||||
let gitConfig = await parseGitConfig(configPath, output);
|
||||
if (!gitConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originUrl: string = gitConfig['remote "origin"']?.url;
|
||||
const originUrl = pluckRemoteUrl(gitConfig);
|
||||
if (originUrl) {
|
||||
return originUrl;
|
||||
}
|
||||
44
packages/cli/src/util/ensure-link.ts
Normal file
44
packages/cli/src/util/ensure-link.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Org, Project } from '../types';
|
||||
import Client from './client';
|
||||
import setupAndLink from './link/setup-and-link';
|
||||
import param from './output/param';
|
||||
import { getCommandName } from './pkg-name';
|
||||
import { getLinkedProject } from './projects/link';
|
||||
|
||||
type LinkResult = {
|
||||
org: Org;
|
||||
project: Project;
|
||||
};
|
||||
export async function ensureLink(
|
||||
commandName: string,
|
||||
client: Client,
|
||||
cwd: string,
|
||||
yes: boolean
|
||||
): Promise<LinkResult | number> {
|
||||
let link = await getLinkedProject(client, cwd);
|
||||
if (link.status === 'not_linked') {
|
||||
link = await setupAndLink(client, cwd, {
|
||||
autoConfirm: yes,
|
||||
successEmoji: 'link',
|
||||
setupMsg: 'Set up',
|
||||
});
|
||||
|
||||
if (link.status === 'not_linked') {
|
||||
// User aborted project linking questions
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (link.status === 'error') {
|
||||
if (link.reason === 'HEADLESS') {
|
||||
client.output.error(
|
||||
`Command ${getCommandName(
|
||||
commandName
|
||||
)} requires confirmation. Use option ${param('--yes')} to confirm.`
|
||||
);
|
||||
}
|
||||
return link.exitCode;
|
||||
}
|
||||
|
||||
return { org: link.org, project: link.project };
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import inquirer from 'inquirer';
|
||||
import Client from '../client';
|
||||
import getUser from '../get-user';
|
||||
import getTeams from '../teams/get-teams';
|
||||
@@ -43,7 +42,7 @@ export default async function selectOrg(
|
||||
return choices[defaultOrgIndex].value;
|
||||
}
|
||||
|
||||
const answers = await inquirer.prompt({
|
||||
const answers = await client.prompt({
|
||||
type: 'list',
|
||||
name: 'org',
|
||||
message: question,
|
||||
|
||||
117
packages/cli/src/util/projects/connect-git-provider.ts
Normal file
117
packages/cli/src/util/projects/connect-git-provider.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import Client from '../client';
|
||||
import { stringify } from 'qs';
|
||||
import { Team } from '../../types';
|
||||
import chalk from 'chalk';
|
||||
import link from '../output/link';
|
||||
|
||||
export async function disconnectGitProvider(
|
||||
client: Client,
|
||||
team: Team | null,
|
||||
projectId: string
|
||||
) {
|
||||
const fetchUrl = `/v4/projects/${projectId}/link?${stringify({
|
||||
teamId: team?.id,
|
||||
})}`;
|
||||
return client.fetch(fetchUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectGitProvider(
|
||||
client: Client,
|
||||
team: Team | null,
|
||||
projectId: string,
|
||||
type: string,
|
||||
repo: string
|
||||
) {
|
||||
const fetchUrl = `/v4/projects/${projectId}/link?${stringify({
|
||||
teamId: team?.id,
|
||||
})}`;
|
||||
return client
|
||||
.fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
repo,
|
||||
}),
|
||||
})
|
||||
.catch(err => {
|
||||
if (
|
||||
err.meta?.action === 'Install GitHub App' ||
|
||||
err.code === 'repo_not_found'
|
||||
) {
|
||||
client.output.error(
|
||||
`Failed to link ${chalk.cyan(
|
||||
repo
|
||||
)}. Make sure there aren't any typos and that you have access to the repository if it's private.`
|
||||
);
|
||||
} else if (err.action === 'Add a Login Connection') {
|
||||
client.output.error(
|
||||
err.message.replace(repo, chalk.cyan(repo)) +
|
||||
`\nVisit ${link(err.link)} for more information.`
|
||||
);
|
||||
} else {
|
||||
client.output.error(
|
||||
`Failed to connect the ${formatProvider(
|
||||
type
|
||||
)} repository ${repo}.\n${err}`
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatProvider(type: string): string {
|
||||
switch (type) {
|
||||
case 'github':
|
||||
return 'GitHub';
|
||||
case 'gitlab':
|
||||
return 'GitLab';
|
||||
case 'bitbucket':
|
||||
return 'Bitbucket';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRepoUrl(originUrl: string): {
|
||||
provider: string;
|
||||
org: string;
|
||||
repo: string;
|
||||
} | null {
|
||||
const isSSH = originUrl.startsWith('git@');
|
||||
// Matches all characters between (// or @) and (.com or .org)
|
||||
// eslint-disable-next-line prefer-named-capture-group
|
||||
const provider = /(?<=(\/\/|@)).*(?=(\.com|\.org))/.exec(originUrl);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let org;
|
||||
let repo;
|
||||
|
||||
if (isSSH) {
|
||||
org = originUrl.split(':')[1].split('/')[0];
|
||||
repo = originUrl.split('/')[1]?.replace('.git', '');
|
||||
} else {
|
||||
// Assume https:// or git://
|
||||
org = originUrl.split('/')[3];
|
||||
repo = originUrl.split('/')[4]?.replace('.git', '');
|
||||
}
|
||||
|
||||
if (!org || !repo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: provider[0],
|
||||
org,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
@@ -348,7 +348,6 @@ function testFixtureStdio(
|
||||
: []),
|
||||
'deploy',
|
||||
'--public',
|
||||
'--no-clipboard',
|
||||
'--debug',
|
||||
],
|
||||
{ cwd, stdio: 'pipe', reject: false }
|
||||
|
||||
1
packages/cli/test/fixtures/unit/commands/list/with-team/.gitignore
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/list/with-team/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!.vercel
|
||||
4
packages/cli/test/fixtures/unit/commands/list/with-team/.vercel/project.json
vendored
Normal file
4
packages/cli/test/fixtures/unit/commands/list/with-team/.vercel/project.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "with-team",
|
||||
"orgId": "team_dummy"
|
||||
}
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/bad-remote-url/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/bad-remote-url/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
10
packages/cli/test/fixtures/unit/commands/projects/connect/bad-remote-url/git/config
generated
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/projects/connect/bad-remote-url/git/config
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[remote "origin"]
|
||||
url = bababooey
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/bad-remote-url/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/bad-remote-url/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/existing-connection/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/existing-connection/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
10
packages/cli/test/fixtures/unit/commands/projects/connect/existing-connection/git/config
generated
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/projects/connect/existing-connection/git/config
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[remote "origin"]
|
||||
url = https://github.com/user2/repo2
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/existing-connection/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/existing-connection/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/invalid-repo/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/invalid-repo/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
10
packages/cli/test/fixtures/unit/commands/projects/connect/invalid-repo/git/config
generated
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/projects/connect/invalid-repo/git/config
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[remote "origin"]
|
||||
url = https://github.com/laksfj/asdgklsadkl
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/invalid-repo/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/invalid-repo/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/new-connection/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/new-connection/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
10
packages/cli/test/fixtures/unit/commands/projects/connect/new-connection/git/config
generated
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/projects/connect/new-connection/git/config
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[remote "origin"]
|
||||
url = https://github.com/user/repo
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/new-connection/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/new-connection/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/no-git-config/file.txt
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/no-git-config/file.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
hi
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/no-remote-url/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/no-remote-url/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
7
packages/cli/test/fixtures/unit/commands/projects/connect/no-remote-url/git/config
generated
vendored
Normal file
7
packages/cli/test/fixtures/unit/commands/projects/connect/no-remote-url/git/config
generated
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/no-remote-url/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/no-remote-url/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/same-repo-connection/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/same-repo-connection/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
10
packages/cli/test/fixtures/unit/commands/projects/connect/same-repo-connection/git/config
generated
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/projects/connect/same-repo-connection/git/config
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[remote "origin"]
|
||||
url = https://github.com/user/repo
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/same-repo-connection/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/same-repo-connection/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/.gitignore
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
1
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/git/HEAD
generated
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/git/HEAD
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
10
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/git/config
generated
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/git/config
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[remote "origin"]
|
||||
url = https://github.com/user/repo.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
6
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/git/info/exclude
generated
vendored
Normal file
6
packages/cli/test/fixtures/unit/commands/projects/connect/unlinked/git/info/exclude
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
33
packages/cli/test/integration.js
vendored
33
packages/cli/test/integration.js
vendored
@@ -553,8 +553,8 @@ test('default command should warn when deploying with conflicting subdirectory',
|
||||
/Did you mean to deploy the subdirectory "list"\? Use `vc --cwd list` instead./
|
||||
);
|
||||
|
||||
const listHeader = /project +latest deployment +state +age +username/;
|
||||
t.regex(stdout || '', listHeader); // ensure `list` command still ran
|
||||
const listHeader = /No deployments found/;
|
||||
t.regex(stderr || '', listHeader); // ensure `list` command still ran
|
||||
});
|
||||
|
||||
test('deploy command should not warn when deploying with conflicting subdirectory and using --cwd', async t => {
|
||||
@@ -577,8 +577,8 @@ test('deploy command should not warn when deploying with conflicting subdirector
|
||||
/Did you mean to deploy the subdirectory "list"\? Use `vc --cwd list` instead./
|
||||
);
|
||||
|
||||
const listHeader = /project +latest deployment +state +age +username/;
|
||||
t.regex(stdout || '', listHeader); // ensure `list` command still ran
|
||||
const listHeader = /No deployments found/;
|
||||
t.regex(stderr || '', listHeader); // ensure `list` command still ran
|
||||
});
|
||||
|
||||
test('default command should work with --cwd option', async t => {
|
||||
@@ -1813,31 +1813,6 @@ test('remove the wildcard alias', async t => {
|
||||
});
|
||||
*/
|
||||
|
||||
test('ensure username in list is right', async t => {
|
||||
const { stdout, stderr, exitCode } = await execa(
|
||||
binaryPath,
|
||||
['ls', ...defaultArgs],
|
||||
{
|
||||
reject: false,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(stderr);
|
||||
console.log(stdout);
|
||||
console.log(exitCode);
|
||||
|
||||
// Ensure the exit code is right
|
||||
t.is(exitCode, 0);
|
||||
|
||||
const line = stdout
|
||||
.split('\n')
|
||||
.find(line => line.includes('.now.sh') || line.includes('.vercel.app'));
|
||||
const columns = line.split(/\s+/);
|
||||
|
||||
// Ensure username column have username
|
||||
t.truthy(columns.pop().includes(contextName));
|
||||
});
|
||||
|
||||
test('ensure we render a warning for deployments with no files', async t => {
|
||||
const directory = fixture('empty-directory');
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import { Build, User } from '../../src/types';
|
||||
let deployments = new Map<string, Deployment>();
|
||||
let deploymentBuilds = new Map<Deployment, Build[]>();
|
||||
|
||||
export function useDeployment({ creator }: { creator: Pick<User, 'id'> }) {
|
||||
export function useDeployment({
|
||||
creator,
|
||||
}: {
|
||||
creator: Pick<User, 'id' | 'email' | 'name'>;
|
||||
}) {
|
||||
const createdAt = Date.now();
|
||||
const url = new URL(chance().url());
|
||||
const deployment: Deployment = {
|
||||
@@ -23,6 +27,11 @@ export function useDeployment({ creator }: { creator: Pick<User, 'id'> }) {
|
||||
createdAt,
|
||||
createdIn: 'sfo1',
|
||||
ownerId: creator.id,
|
||||
creator: {
|
||||
uid: creator.id,
|
||||
email: creator.email,
|
||||
username: creator.name,
|
||||
},
|
||||
readyState: 'READY',
|
||||
env: {},
|
||||
build: { env: {} },
|
||||
@@ -77,4 +86,9 @@ beforeEach(() => {
|
||||
const builds = deploymentBuilds.get(deployment);
|
||||
res.json({ builds });
|
||||
});
|
||||
|
||||
client.scenario.get('/:version/now/deployments', (req, res) => {
|
||||
const deploymentsList = Array.from(deployments.values());
|
||||
res.json({ deployments: deploymentsList });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { client } from './client';
|
||||
import { Project } from '../../src/types';
|
||||
import { formatProvider } from '../../src/util/projects/connect-git-provider';
|
||||
|
||||
const envs = [
|
||||
{
|
||||
@@ -157,6 +158,49 @@ export function useProject(project: Partial<Project> = defaultProject) {
|
||||
|
||||
res.json({ envs });
|
||||
});
|
||||
client.scenario.post(`/v4/projects/${project.id}/link`, (req, res) => {
|
||||
const { type, repo, org } = req.body;
|
||||
if (
|
||||
(type === 'github' || type === 'gitlab' || type === 'bitbucket') &&
|
||||
(repo === 'user/repo' || repo === 'user2/repo2')
|
||||
) {
|
||||
project.link = {
|
||||
type,
|
||||
repo,
|
||||
repoId: 1010,
|
||||
org,
|
||||
gitCredentialId: '',
|
||||
sourceless: true,
|
||||
createdAt: 1656109539791,
|
||||
updatedAt: 1656109539791,
|
||||
};
|
||||
res.json(project);
|
||||
} else {
|
||||
if (type === 'github') {
|
||||
res.status(400).json({
|
||||
message: `To link a GitHub repository, you need to install the GitHub integration first. (400)\nInstall GitHub App: https://github.com/apps/vercel`,
|
||||
meta: {
|
||||
action: 'Install GitHub App',
|
||||
link: 'https://github.com/apps/vercel',
|
||||
repo,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
code: 'repo_not_found',
|
||||
message: `The repository "${repo}" couldn't be found in your linked ${formatProvider(
|
||||
type
|
||||
)} account.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
client.scenario.delete(`/v4/projects/${project.id}/link`, (req, res) => {
|
||||
if (project.link) {
|
||||
project.link = undefined;
|
||||
}
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
return { project, envs };
|
||||
}
|
||||
|
||||
139
packages/cli/test/unit/commands/list.test.ts
Normal file
139
packages/cli/test/unit/commands/list.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { client, MockClient } from '../../mocks/client';
|
||||
import { useUser } from '../../mocks/user';
|
||||
import list, { stateString } from '../../../src/commands/list';
|
||||
import { join } from 'path';
|
||||
import { useTeams } from '../../mocks/team';
|
||||
import { defaultProject, useProject } from '../../mocks/project';
|
||||
import { useDeployment } from '../../mocks/deployment';
|
||||
|
||||
const fixture = (name: string) =>
|
||||
join(__dirname, '../../fixtures/unit/commands/list', name);
|
||||
|
||||
describe('list', () => {
|
||||
const originalCwd = process.cwd();
|
||||
let teamSlug: string = '';
|
||||
|
||||
it('should get deployments from a project linked by a directory', async () => {
|
||||
const cwd = fixture('with-team');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
|
||||
const user = useUser();
|
||||
const team = useTeams('team_dummy');
|
||||
teamSlug = team[0].slug;
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'with-team',
|
||||
name: 'with-team',
|
||||
});
|
||||
const deployment = useDeployment({ creator: user });
|
||||
|
||||
await list(client);
|
||||
|
||||
const output = await readOutputStream(client);
|
||||
|
||||
const { org } = getDataFromIntro(output.split('\n')[0]);
|
||||
const header: string[] = parseTable(output.split('\n')[2]);
|
||||
const data: string[] = parseTable(output.split('\n')[3]);
|
||||
data.splice(2, 1);
|
||||
|
||||
expect(org).toEqual(team[0].slug);
|
||||
expect(header).toEqual([
|
||||
'project',
|
||||
'latest deployment',
|
||||
'state',
|
||||
'age',
|
||||
'username',
|
||||
]);
|
||||
|
||||
expect(data).toEqual([
|
||||
deployment.url,
|
||||
stateString(deployment.state || ''),
|
||||
user.name,
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should get the deployments for a specified project', async () => {
|
||||
const cwd = fixture('with-team');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
|
||||
const user = useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'with-team',
|
||||
name: 'with-team',
|
||||
});
|
||||
const deployment = useDeployment({ creator: user });
|
||||
|
||||
client.setArgv(deployment.name);
|
||||
await list(client);
|
||||
|
||||
const output = await readOutputStream(client);
|
||||
|
||||
const { org } = getDataFromIntro(output.split('\n')[0]);
|
||||
const header: string[] = parseTable(output.split('\n')[2]);
|
||||
const data: string[] = parseTable(output.split('\n')[3]);
|
||||
data.splice(2, 1);
|
||||
|
||||
expect(org).toEqual(teamSlug);
|
||||
|
||||
expect(header).toEqual([
|
||||
'project',
|
||||
'latest deployment',
|
||||
'state',
|
||||
'age',
|
||||
'username',
|
||||
]);
|
||||
expect(data).toEqual([
|
||||
deployment.url,
|
||||
stateString(deployment.state || ''),
|
||||
user.name,
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getDataFromIntro(output: string): {
|
||||
project: string | undefined;
|
||||
org: string | undefined;
|
||||
} {
|
||||
const project = output.match(/(?<=Deployments for )(.*)(?= under)/);
|
||||
const org = output.match(/(?<=under )(.*)(?= \[)/);
|
||||
|
||||
return {
|
||||
project: project?.[0],
|
||||
org: org?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
function parseTable(output: string): string[] {
|
||||
return output
|
||||
.trim()
|
||||
.replace(/ {3} +/g, ',')
|
||||
.split(',');
|
||||
}
|
||||
|
||||
function readOutputStream(client: MockClient): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const timeout = setTimeout(() => {
|
||||
reject();
|
||||
}, 3000);
|
||||
|
||||
client.stderr.resume();
|
||||
client.stderr.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
if (chunks.length === 3) {
|
||||
clearTimeout(timeout);
|
||||
resolve(chunks.toString().replace(/,/g, ''));
|
||||
}
|
||||
});
|
||||
client.stderr.on('error', reject);
|
||||
});
|
||||
}
|
||||
272
packages/cli/test/unit/commands/projects.test.ts
Normal file
272
packages/cli/test/unit/commands/projects.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { join } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import projects from '../../../src/commands/projects';
|
||||
import { useUser } from '../../mocks/user';
|
||||
import { useTeams } from '../../mocks/team';
|
||||
import { defaultProject, useProject } from '../../mocks/project';
|
||||
import { client } from '../../mocks/client';
|
||||
import { Project } from '../../../src/types';
|
||||
|
||||
describe('projects', () => {
|
||||
describe('connect', () => {
|
||||
const originalCwd = process.cwd();
|
||||
const fixture = (name: string) =>
|
||||
join(__dirname, '../../fixtures/unit/commands/projects/connect', name);
|
||||
|
||||
it('connects an unlinked project', async () => {
|
||||
const cwd = fixture('unlinked');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'unlinked',
|
||||
name: 'unlinked',
|
||||
});
|
||||
client.setArgv('projects', 'connect');
|
||||
const projectsPromise = projects(client);
|
||||
|
||||
await expect(client.stderr).toOutput('Set up');
|
||||
client.stdin.write('y\n');
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
'Which scope should contain your project?'
|
||||
);
|
||||
client.stdin.write('\r');
|
||||
|
||||
await expect(client.stderr).toOutput('Found project');
|
||||
client.stdin.write('y\n');
|
||||
|
||||
const exitCode = await projectsPromise;
|
||||
await expect(client.stderr).toOutput(
|
||||
'Connected GitHub repository user/repo!'
|
||||
);
|
||||
|
||||
expect(exitCode).toEqual(0);
|
||||
|
||||
const project: Project = await client.fetch(`/v8/projects/unlinked`);
|
||||
expect(project.link).toMatchObject({
|
||||
type: 'github',
|
||||
repo: 'user/repo',
|
||||
repoId: 1010,
|
||||
gitCredentialId: '',
|
||||
sourceless: true,
|
||||
createdAt: 1656109539791,
|
||||
updatedAt: 1656109539791,
|
||||
});
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should fail when there is no git config', async () => {
|
||||
const cwd = fixture('no-git-config');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'no-git-config',
|
||||
name: 'no-git-config',
|
||||
});
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
expect(exitCode).toEqual(1);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Error! No local git repo found. Run \`git clone <url>\` to clone a remote Git repository first.\n`
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should fail when there is no remote url', async () => {
|
||||
const cwd = fixture('no-remote-url');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'no-remote-url',
|
||||
name: 'no-remote-url',
|
||||
});
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
expect(exitCode).toEqual(1);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Error! No remote origin URL found in your Git config. Make sure you've connected your local Git repo to a Git provider first.\n`
|
||||
);
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should fail when the remote url is bad', async () => {
|
||||
const cwd = fixture('bad-remote-url');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'bad-remote-url',
|
||||
name: 'bad-remote-url',
|
||||
});
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
expect(exitCode).toEqual(1);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Error! Failed to parse Git repo data from the following remote URL in your Git config: bababooey\n`
|
||||
);
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should connect a repo to a project that is not already connected', async () => {
|
||||
const cwd = fixture('new-connection');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'new-connection',
|
||||
name: 'new-connection',
|
||||
});
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
|
||||
const project: Project = await client.fetch(
|
||||
`/v8/projects/new-connection`
|
||||
);
|
||||
expect(project.link).toMatchObject({
|
||||
type: 'github',
|
||||
repo: 'user/repo',
|
||||
repoId: 1010,
|
||||
gitCredentialId: '',
|
||||
sourceless: true,
|
||||
createdAt: 1656109539791,
|
||||
updatedAt: 1656109539791,
|
||||
});
|
||||
expect(client.stderr).toOutput(
|
||||
`> Connected GitHub repository user/repo!\n`
|
||||
);
|
||||
expect(exitCode).toEqual(0);
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should replace an old connection with a new one', async () => {
|
||||
const cwd = fixture('existing-connection');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
const project = useProject({
|
||||
...defaultProject,
|
||||
id: 'existing-connection',
|
||||
name: 'existing-connection',
|
||||
});
|
||||
project.project.link = {
|
||||
type: 'github',
|
||||
repo: 'repo',
|
||||
org: 'user',
|
||||
repoId: 1010,
|
||||
gitCredentialId: '',
|
||||
sourceless: true,
|
||||
createdAt: 1656109539791,
|
||||
updatedAt: 1656109539791,
|
||||
};
|
||||
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
|
||||
const newProjectData: Project = await client.fetch(
|
||||
`/v8/projects/existing-connection`
|
||||
);
|
||||
expect(newProjectData.link).toMatchObject({
|
||||
type: 'github',
|
||||
repo: 'user2/repo2',
|
||||
repoId: 1010,
|
||||
gitCredentialId: '',
|
||||
sourceless: true,
|
||||
createdAt: 1656109539791,
|
||||
updatedAt: 1656109539791,
|
||||
});
|
||||
await expect(client.stderr).toOutput(
|
||||
`> Connected GitHub repository user2/repo2!\n`
|
||||
);
|
||||
expect(exitCode).toEqual(0);
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should exit when an already-connected repo is connected', async () => {
|
||||
const cwd = fixture('new-connection');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
const project = useProject({
|
||||
...defaultProject,
|
||||
id: 'new-connection',
|
||||
name: 'new-connection',
|
||||
});
|
||||
project.project.link = {
|
||||
type: 'github',
|
||||
repo: 'repo',
|
||||
org: 'user',
|
||||
repoId: 1010,
|
||||
gitCredentialId: '',
|
||||
sourceless: true,
|
||||
createdAt: 1656109539791,
|
||||
updatedAt: 1656109539791,
|
||||
};
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
expect(exitCode).toEqual(1);
|
||||
await expect(client.stderr).toOutput(
|
||||
`> user/repo is already connected to your project.\n`
|
||||
);
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
it('should fail when it cannot find the repository', async () => {
|
||||
const cwd = fixture('invalid-repo');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'invalid-repo',
|
||||
name: 'invalid-repo',
|
||||
});
|
||||
|
||||
client.setArgv('projects', 'connect', '--yes');
|
||||
const exitCode = await projects(client);
|
||||
expect(exitCode).toEqual(1);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Failed to link laksfj/asdgklsadkl. Make sure there aren't any typos and that you have access to the repository if it's private.`
|
||||
);
|
||||
} finally {
|
||||
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createGitMeta,
|
||||
getRemoteUrl,
|
||||
isDirty,
|
||||
} from '../../../../src/util/deploy/create-git-meta';
|
||||
} from '../../../../src/util/create-git-meta';
|
||||
import { client } from '../../../mocks/client';
|
||||
|
||||
const fixture = (name: string) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/client",
|
||||
"version": "12.0.4",
|
||||
"version": "12.0.5-canary.0",
|
||||
"main": "dist/index.js",
|
||||
"typings": "dist/index.d.ts",
|
||||
"homepage": "https://vercel.com",
|
||||
@@ -42,7 +42,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/build-utils": "5.0.0",
|
||||
"@vercel/build-utils": "5.0.1-canary.0",
|
||||
"@vercel/routing-utils": "1.13.5",
|
||||
"@zeit/fetch": "5.2.0",
|
||||
"async-retry": "1.2.3",
|
||||
|
||||
@@ -63,6 +63,12 @@ export interface Deployment {
|
||||
| 'ERROR';
|
||||
createdAt: number;
|
||||
createdIn: string;
|
||||
buildingAt?: number;
|
||||
creator?: {
|
||||
uid?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
};
|
||||
env: Dictionary<string>;
|
||||
build: {
|
||||
env: Dictionary<string>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/go",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5-canary.0",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index",
|
||||
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/go",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@types/fs-extra": "^5.0.5",
|
||||
"@types/node-fetch": "^2.3.0",
|
||||
"@types/tar": "^4.0.0",
|
||||
"@vercel/build-utils": "5.0.0",
|
||||
"@vercel/build-utils": "5.0.1-canary.0",
|
||||
"@vercel/ncc": "0.24.0",
|
||||
"async-retry": "1.3.1",
|
||||
"execa": "^1.0.0",
|
||||
|
||||
12
packages/hydrogen/build.js
Normal file
12
packages/hydrogen/build.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const execa = require('execa');
|
||||
const { remove } = require('fs-extra');
|
||||
|
||||
async function main() {
|
||||
await remove('dist');
|
||||
await execa('tsc', [], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
12
packages/hydrogen/edge-entry.js
Normal file
12
packages/hydrogen/edge-entry.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import handleRequest from '__RELATIVE__/src/App.server';
|
||||
import indexTemplate from '__RELATIVE__/dist/client/index.html?raw';
|
||||
|
||||
// ReadableStream is bugged in Vercel Edge, overwrite with polyfill
|
||||
import { ReadableStream } from 'web-streams-polyfill/ponyfill';
|
||||
Object.assign(globalThis, { ReadableStream });
|
||||
|
||||
export default (request, event) =>
|
||||
handleRequest(request, {
|
||||
indexTemplate,
|
||||
context: event,
|
||||
});
|
||||
5
packages/hydrogen/jest.config.js
Normal file
5
packages/hydrogen/jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
28
packages/hydrogen/package.json
Normal file
28
packages/hydrogen/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@vercel/hydrogen",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"homepage": "https://vercel.com/docs",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vercel/vercel.git",
|
||||
"directory": "packages/hydrogen"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"test-integration-once": "yarn test test/test.js",
|
||||
"test": "jest --env node --verbose --bail --runInBand",
|
||||
"prepublishOnly": "node build.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"edge-entry.js"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/jest": "27.5.1",
|
||||
"@types/node": "*",
|
||||
"@vercel/build-utils": "5.0.1-canary.0",
|
||||
"typescript": "4.6.4"
|
||||
}
|
||||
}
|
||||
147
packages/hydrogen/src/build.ts
Normal file
147
packages/hydrogen/src/build.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { dirname, join, relative } from 'path';
|
||||
import {
|
||||
debug,
|
||||
download,
|
||||
EdgeFunction,
|
||||
execCommand,
|
||||
getEnvForPackageManager,
|
||||
getNodeVersion,
|
||||
getSpawnOptions,
|
||||
glob,
|
||||
readConfigFile,
|
||||
runNpmInstall,
|
||||
runPackageJsonScript,
|
||||
scanParentDirs,
|
||||
} from '@vercel/build-utils';
|
||||
import type { BuildV2, PackageJson } from '@vercel/build-utils';
|
||||
|
||||
export const build: BuildV2 = async ({
|
||||
entrypoint,
|
||||
files,
|
||||
workPath,
|
||||
config,
|
||||
meta = {},
|
||||
}) => {
|
||||
const { installCommand, buildCommand } = config;
|
||||
|
||||
await download(files, workPath, meta);
|
||||
|
||||
const mountpoint = dirname(entrypoint);
|
||||
const entrypointDir = join(workPath, mountpoint);
|
||||
|
||||
// Run "Install Command"
|
||||
const nodeVersion = await getNodeVersion(
|
||||
entrypointDir,
|
||||
undefined,
|
||||
config,
|
||||
meta
|
||||
);
|
||||
|
||||
const spawnOpts = getSpawnOptions(meta, nodeVersion);
|
||||
const { cliType, lockfileVersion } = await scanParentDirs(entrypointDir);
|
||||
|
||||
spawnOpts.env = getEnvForPackageManager({
|
||||
cliType,
|
||||
lockfileVersion,
|
||||
nodeVersion,
|
||||
env: spawnOpts.env || {},
|
||||
});
|
||||
|
||||
if (typeof installCommand === 'string') {
|
||||
if (installCommand.trim()) {
|
||||
console.log(`Running "install" command: \`${installCommand}\`...`);
|
||||
await execCommand(installCommand, {
|
||||
...spawnOpts,
|
||||
cwd: entrypointDir,
|
||||
});
|
||||
} else {
|
||||
console.log(`Skipping "install" command...`);
|
||||
}
|
||||
} else {
|
||||
await runNpmInstall(entrypointDir, [], spawnOpts, meta, nodeVersion);
|
||||
}
|
||||
|
||||
// Copy the edge entrypoint file into `.vercel/cache`
|
||||
const edgeEntryDir = join(workPath, '.vercel/cache/hydrogen');
|
||||
const edgeEntryRelative = relative(edgeEntryDir, workPath);
|
||||
const edgeEntryDest = join(edgeEntryDir, 'edge-entry.js');
|
||||
let edgeEntryContents = await fs.readFile(
|
||||
join(__dirname, '..', 'edge-entry.js'),
|
||||
'utf8'
|
||||
);
|
||||
edgeEntryContents = edgeEntryContents.replace(
|
||||
/__RELATIVE__/g,
|
||||
edgeEntryRelative
|
||||
);
|
||||
await fs.mkdir(edgeEntryDir, { recursive: true });
|
||||
await fs.writeFile(edgeEntryDest, edgeEntryContents);
|
||||
|
||||
// Make `shopify hydrogen build` output a Edge Function compatible bundle
|
||||
spawnOpts.env.SHOPIFY_FLAG_BUILD_TARGET = 'worker';
|
||||
|
||||
// Use this file as the entrypoint for the Edge Function bundle build
|
||||
spawnOpts.env.SHOPIFY_FLAG_BUILD_SSR_ENTRY = edgeEntryDest;
|
||||
|
||||
// Run "Build Command"
|
||||
if (buildCommand) {
|
||||
debug(`Executing build command "${buildCommand}"`);
|
||||
await execCommand(buildCommand, {
|
||||
...spawnOpts,
|
||||
cwd: entrypointDir,
|
||||
});
|
||||
} else {
|
||||
const pkg = await readConfigFile<PackageJson>(
|
||||
join(entrypointDir, 'package.json')
|
||||
);
|
||||
if (hasScript('vercel-build', pkg)) {
|
||||
debug(`Executing "yarn vercel-build"`);
|
||||
await runPackageJsonScript(entrypointDir, 'vercel-build', spawnOpts);
|
||||
} else if (hasScript('build', pkg)) {
|
||||
debug(`Executing "yarn build"`);
|
||||
await runPackageJsonScript(entrypointDir, 'build', spawnOpts);
|
||||
} else {
|
||||
await execCommand('shopify hydrogen build', {
|
||||
...spawnOpts,
|
||||
cwd: entrypointDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [staticFiles, edgeFunctionFiles] = await Promise.all([
|
||||
glob('**', join(entrypointDir, 'dist/client')),
|
||||
glob('**', join(entrypointDir, 'dist/worker')),
|
||||
]);
|
||||
|
||||
const edgeFunction = new EdgeFunction({
|
||||
name: 'hydrogen',
|
||||
deploymentTarget: 'v8-worker',
|
||||
entrypoint: 'index.js',
|
||||
files: edgeFunctionFiles,
|
||||
});
|
||||
|
||||
// The `index.html` file is a template, but we want to serve the
|
||||
// SSR version instead, so omit this static file from the output
|
||||
delete staticFiles['index.html'];
|
||||
|
||||
return {
|
||||
routes: [
|
||||
{
|
||||
handle: 'filesystem',
|
||||
},
|
||||
{
|
||||
src: '/(.*)',
|
||||
dest: '/hydrogen',
|
||||
},
|
||||
],
|
||||
output: {
|
||||
hydrogen: edgeFunction,
|
||||
...staticFiles,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function hasScript(scriptName: string, pkg: PackageJson | null) {
|
||||
const scripts = pkg?.scripts || {};
|
||||
return typeof scripts[scriptName] === 'string';
|
||||
}
|
||||
3
packages/hydrogen/src/index.ts
Normal file
3
packages/hydrogen/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const version = 2;
|
||||
export * from './build';
|
||||
export * from './prepare-cache';
|
||||
6
packages/hydrogen/src/prepare-cache.ts
Normal file
6
packages/hydrogen/src/prepare-cache.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { glob } from '@vercel/build-utils';
|
||||
import type { PrepareCache } from '@vercel/build-utils';
|
||||
|
||||
export const prepareCache: PrepareCache = ({ repoRootPath, workPath }) => {
|
||||
return glob('**/node_modules/**', repoRootPath || workPath);
|
||||
};
|
||||
18
packages/hydrogen/test/fixtures/demo-store-js/.devcontainer/devcontainer.json
vendored
Normal file
18
packages/hydrogen/test/fixtures/demo-store-js/.devcontainer/devcontainer.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Shopify Hydrogen",
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16",
|
||||
"settings": {},
|
||||
"extensions": [
|
||||
"graphql.vscode-graphql",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"forwardPorts": [3000],
|
||||
"postCreateCommand": "yarn install",
|
||||
"postStartCommand": "yarn dev",
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"git": "latest"
|
||||
}
|
||||
}
|
||||
80
packages/hydrogen/test/fixtures/demo-store-js/.gitignore
vendored
Normal file
80
packages/hydrogen/test/fixtures/demo-store-js/.gitignore
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
# THIS IS A STUB FOR NEW HYDROGEN APPS
|
||||
# THIS WILL EVENTUALLY MOVE TO A /TEMPLATE-* FOLDER
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Vite output
|
||||
dist
|
||||
46
packages/hydrogen/test/fixtures/demo-store-js/README.md
vendored
Normal file
46
packages/hydrogen/test/fixtures/demo-store-js/README.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Hydrogen Demo Store
|
||||
|
||||
Hydrogen is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts.
|
||||
|
||||
[Check out the docs](https://shopify.dev/custom-storefronts/hydrogen)
|
||||
|
||||
[Run this template on StackBlitz](https://stackblitz.com/github/Shopify/hydrogen/tree/stackblitz/templates/demo-store)
|
||||
|
||||
## Getting started
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Node.js version 16.5.0 or higher
|
||||
- Yarn
|
||||
|
||||
To create a new Hydrogen app, run:
|
||||
|
||||
```bash
|
||||
npm init @shopify/hydrogen
|
||||
```
|
||||
|
||||
## Running the dev server
|
||||
|
||||
Then `cd` into the new directory and run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Remember to update `hydrogen.config.js` with your shop's domain and Storefront API token!
|
||||
|
||||
## Building for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Previewing a production build
|
||||
|
||||
To run a local preview of your Hydrogen app in an environment similar to Oxygen, build your Hydrogen app and then run `npm run preview`:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
18
packages/hydrogen/test/fixtures/demo-store-js/hydrogen.config.js
vendored
Normal file
18
packages/hydrogen/test/fixtures/demo-store-js/hydrogen.config.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import {defineConfig, CookieSessionStorage} from '@shopify/hydrogen/config';
|
||||
|
||||
export default defineConfig({
|
||||
shopify: {
|
||||
defaultCountryCode: 'US',
|
||||
defaultLanguageCode: 'EN',
|
||||
storeDomain: 'hydrogen-preview.myshopify.com',
|
||||
storefrontToken: '3b580e70970c4528da70c98e097c2fa0',
|
||||
storefrontApiVersion: '2022-07',
|
||||
},
|
||||
session: CookieSessionStorage('__session', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'Strict',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
}),
|
||||
});
|
||||
17
packages/hydrogen/test/fixtures/demo-store-js/index.html
vendored
Normal file
17
packages/hydrogen/test/fixtures/demo-store-js/index.html
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hydrogen</title>
|
||||
<link rel="stylesheet" href="/src/styles/index.css" />
|
||||
<link rel="preconnect" href="https://cdn.shopify.com" />
|
||||
<link rel="preconnect" href="https://shop.app/" />
|
||||
<link rel="preconnect" href="https://hydrogen-preview.myshopify.com/" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/@shopify/hydrogen/entry-client"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
packages/hydrogen/test/fixtures/demo-store-js/jsconfig.json
vendored
Normal file
12
packages/hydrogen/test/fixtures/demo-store-js/jsconfig.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node16",
|
||||
"lib": ["dom", "dom.iterable", "scripthost", "es2020"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["**/*.js", "**/*.jsx"]
|
||||
}
|
||||
15774
packages/hydrogen/test/fixtures/demo-store-js/package-lock.json
generated
vendored
Normal file
15774
packages/hydrogen/test/fixtures/demo-store-js/package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/hydrogen/test/fixtures/demo-store-js/package.json
vendored
Normal file
44
packages/hydrogen/test/fixtures/demo-store-js/package.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "demo-store-js",
|
||||
"description": "Demo store template for @shopify/hydrogen",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "shopify hydrogen dev",
|
||||
"build": "shopify hydrogen build",
|
||||
"preview": "shopify hydrogen preview",
|
||||
"lint-ts": "tsc --noEmit",
|
||||
"test": "WATCH=true vitest",
|
||||
"test:ci": "yarn build -t node && vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shopify/cli": "3.0.25",
|
||||
"@shopify/cli-hydrogen": "3.0.25",
|
||||
"@shopify/prettier-config": "^1.1.2",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.2",
|
||||
"playwright": "^1.22.2",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-import": "^14.1.0",
|
||||
"postcss-preset-env": "^7.6.0",
|
||||
"prettier": "^2.3.2",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"vite": "^2.9.0",
|
||||
"vitest": "^0.15.2"
|
||||
},
|
||||
"prettier": "@shopify/prettier-config",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.6.4",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@shopify/hydrogen": "^1.0.2",
|
||||
"clsx": "^1.1.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-use": "^17.4.0",
|
||||
"title": "^3.4.4",
|
||||
"typographic-base": "^1.0.4"
|
||||
},
|
||||
"author": "nrajlich"
|
||||
}
|
||||
10
packages/hydrogen/test/fixtures/demo-store-js/postcss.config.js
vendored
Normal file
10
packages/hydrogen/test/fixtures/demo-store-js/postcss.config.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
'postcss-preset-env': {
|
||||
features: {'nesting-rules': false},
|
||||
},
|
||||
},
|
||||
};
|
||||
BIN
packages/hydrogen/test/fixtures/demo-store-js/public/fonts/IBMPlexSerif-Text.woff2
vendored
Normal file
BIN
packages/hydrogen/test/fixtures/demo-store-js/public/fonts/IBMPlexSerif-Text.woff2
vendored
Normal file
Binary file not shown.
BIN
packages/hydrogen/test/fixtures/demo-store-js/public/fonts/IBMPlexSerif-TextItalic.woff2
vendored
Normal file
BIN
packages/hydrogen/test/fixtures/demo-store-js/public/fonts/IBMPlexSerif-TextItalic.woff2
vendored
Normal file
Binary file not shown.
46
packages/hydrogen/test/fixtures/demo-store-js/src/App.server.jsx
vendored
Normal file
46
packages/hydrogen/test/fixtures/demo-store-js/src/App.server.jsx
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
import {Suspense} from 'react';
|
||||
import renderHydrogen from '@shopify/hydrogen/entry-server';
|
||||
import {
|
||||
FileRoutes,
|
||||
PerformanceMetrics,
|
||||
PerformanceMetricsDebug,
|
||||
Route,
|
||||
Router,
|
||||
ShopifyAnalytics,
|
||||
ShopifyProvider,
|
||||
CartProvider,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {HeaderFallback} from '~/components';
|
||||
import {DefaultSeo, NotFound} from '~/components/index.server';
|
||||
|
||||
function App({request}) {
|
||||
const pathname = new URL(request.normalizedUrl).pathname;
|
||||
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
|
||||
const countryCode = localeMatch ? localeMatch[1] : undefined;
|
||||
|
||||
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<HeaderFallback isHome={isHome} />}>
|
||||
<ShopifyProvider countryCode={countryCode}>
|
||||
<CartProvider countryCode={countryCode}>
|
||||
<Suspense>
|
||||
<DefaultSeo />
|
||||
</Suspense>
|
||||
<Router>
|
||||
<FileRoutes
|
||||
basePath={countryCode ? `/${countryCode}/` : undefined}
|
||||
/>
|
||||
<Route path="*" page={<NotFound />} />
|
||||
</Router>
|
||||
</CartProvider>
|
||||
<PerformanceMetrics />
|
||||
{import.meta.env.DEV && <PerformanceMetricsDebug />}
|
||||
<ShopifyAnalytics />
|
||||
</ShopifyProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default renderHydrogen(App);
|
||||
28
packages/hydrogen/test/fixtures/demo-store-js/src/assets/favicon.svg
vendored
Normal file
28
packages/hydrogen/test/fixtures/demo-store-js/src/assets/favicon.svg
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
|
||||
<style>
|
||||
.stroke {
|
||||
stroke: #000;
|
||||
}
|
||||
.fill {
|
||||
fill: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.stroke {
|
||||
stroke: #fff;
|
||||
}
|
||||
.fill {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
class="stroke"
|
||||
fill-rule="evenodd"
|
||||
d="M16.1 16.04 1 8.02 6.16 5.3l5.82 3.09 4.88-2.57-5.82-3.1L16.21 0l15.1 8.02-5.17 2.72-5.5-2.91-4.88 2.57 5.5 2.92-5.16 2.72Z"
|
||||
/>
|
||||
<path
|
||||
class="fill"
|
||||
fill-rule="evenodd"
|
||||
d="M16.1 32 1 23.98l5.16-2.72 5.82 3.08 4.88-2.57-5.82-3.08 5.17-2.73 15.1 8.02-5.17 2.72-5.5-2.92-4.88 2.58 5.5 2.92L16.1 32Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
125
packages/hydrogen/test/fixtures/demo-store-js/src/components/CountrySelector.client.jsx
vendored
Normal file
125
packages/hydrogen/test/fixtures/demo-store-js/src/components/CountrySelector.client.jsx
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
import {useCallback, useState, Suspense} from 'react';
|
||||
import {useLocalization, fetchSync} from '@shopify/hydrogen';
|
||||
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
|
||||
import {Listbox} from '@headlessui/react';
|
||||
|
||||
import {IconCheck, IconCaret} from '~/components';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
/**
|
||||
* A client component that selects the appropriate country to display for products on a website
|
||||
*/
|
||||
export function CountrySelector() {
|
||||
const [listboxOpen, setListboxOpen] = useState(false);
|
||||
const {
|
||||
country: {isoCode},
|
||||
} = useLocalization();
|
||||
const currentCountry = useMemo(() => {
|
||||
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
|
||||
type: 'region',
|
||||
});
|
||||
|
||||
return {
|
||||
name: regionNamesInEnglish.of(isoCode),
|
||||
isoCode: isoCode,
|
||||
};
|
||||
}, [isoCode]);
|
||||
|
||||
const setCountry = useCallback(
|
||||
({isoCode: newIsoCode}) => {
|
||||
const currentPath = window.location.pathname;
|
||||
let redirectPath;
|
||||
|
||||
if (newIsoCode !== 'US') {
|
||||
if (currentCountry.isoCode === 'US') {
|
||||
redirectPath = `/${newIsoCode.toLowerCase()}${currentPath}`;
|
||||
} else {
|
||||
redirectPath = `/${newIsoCode.toLowerCase()}${currentPath.substring(
|
||||
currentPath.indexOf('/', 1),
|
||||
)}`;
|
||||
}
|
||||
} else {
|
||||
redirectPath = `${currentPath.substring(currentPath.indexOf('/', 1))}`;
|
||||
}
|
||||
|
||||
window.location.href = redirectPath;
|
||||
},
|
||||
[currentCountry],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Listbox onChange={setCountry}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({open}) => {
|
||||
setTimeout(() => setListboxOpen(open));
|
||||
return (
|
||||
<>
|
||||
<Listbox.Button
|
||||
className={`flex items-center justify-between w-full py-3 px-4 border ${
|
||||
open ? 'rounded-b md:rounded-t md:rounded-b-none' : 'rounded'
|
||||
} border-contrast/30 dark:border-white`}
|
||||
>
|
||||
<span className="">{currentCountry.name}</span>
|
||||
<IconCaret direction={open ? 'up' : 'down'} />
|
||||
</Listbox.Button>
|
||||
|
||||
<Listbox.Options
|
||||
className={`border-t-contrast/30 border-contrast/30 bg-primary dark:bg-contrast absolute bottom-12 z-10 grid
|
||||
h-48 w-full overflow-y-scroll rounded-t border dark:border-white px-2 py-2
|
||||
transition-[max-height] duration-150 sm:bottom-auto md:rounded-b md:rounded-t-none
|
||||
md:border-t-0 md:border-b ${
|
||||
listboxOpen ? 'max-h-48' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
{listboxOpen && (
|
||||
<Suspense fallback={<div className="p-2">Loading…</div>}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
<Countries
|
||||
selectedCountry={currentCountry}
|
||||
getClassName={(active) => {
|
||||
return `text-contrast dark:text-primary bg-primary
|
||||
dark:bg-contrast w-full p-2 transition rounded
|
||||
flex justify-start items-center text-left cursor-pointer ${
|
||||
active ? 'bg-primary/10' : null
|
||||
}`;
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Countries({selectedCountry, getClassName}) {
|
||||
const countries = fetchSync('/api/countries').json();
|
||||
|
||||
return (countries || []).map((country) => {
|
||||
const isSelected = country.isoCode === selectedCountry.isoCode;
|
||||
|
||||
return (
|
||||
<Listbox.Option key={country.isoCode} value={country}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({active}) => (
|
||||
<div
|
||||
className={`text-contrast dark:text-primary ${getClassName(
|
||||
active,
|
||||
)}`}
|
||||
>
|
||||
{country.name}
|
||||
{isSelected ? (
|
||||
<span className="ml-2">
|
||||
<IconCheck />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
);
|
||||
});
|
||||
}
|
||||
22
packages/hydrogen/test/fixtures/demo-store-js/src/components/CustomFont.client.jsx
vendored
Normal file
22
packages/hydrogen/test/fixtures/demo-store-js/src/components/CustomFont.client.jsx
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
// When making building your custom storefront, you will most likely want to
|
||||
// use custom fonts as well. These are often implemented without critical
|
||||
// performance optimizations.
|
||||
|
||||
// Below, you'll find the markup needed to optimally render a pair of web fonts
|
||||
// that we will use on our journal articles. This typeface, IBM Plex,
|
||||
// can be found at: https://www.ibm.com/plex/, as well as on
|
||||
// Google Fonts: https://fonts.google.com/specimen/IBM+Plex+Serif. We included
|
||||
// these locally since you’ll most likely be using commercially licensed fonts.
|
||||
|
||||
// When implementing a custom font, specifying the Unicode range you need,
|
||||
// and using `font-display: swap` will help you improve your performance.
|
||||
|
||||
// For fonts that appear in the critical rendering path, you can speed up
|
||||
// performance even more by including a <link> tag in your HTML.
|
||||
|
||||
// In a production environment, you will likely want to include the below
|
||||
// markup right in your index.html and index.css files.
|
||||
|
||||
import '../styles/custom-font.css';
|
||||
|
||||
export function CustomFont() {}
|
||||
37
packages/hydrogen/test/fixtures/demo-store-js/src/components/DefaultSeo.server.jsx
vendored
Normal file
37
packages/hydrogen/test/fixtures/demo-store-js/src/components/DefaultSeo.server.jsx
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import {CacheLong, gql, Seo, useShopQuery} from '@shopify/hydrogen';
|
||||
|
||||
/**
|
||||
* A server component that fetches a `shop.name` and sets default values and templates for every page on a website
|
||||
*/
|
||||
export function DefaultSeo() {
|
||||
const {
|
||||
data: {
|
||||
shop: {name, description},
|
||||
},
|
||||
} = useShopQuery({
|
||||
query: SHOP_QUERY,
|
||||
cache: CacheLong(),
|
||||
preload: '*',
|
||||
});
|
||||
|
||||
return (
|
||||
// @ts-ignore TODO: Fix types
|
||||
<Seo
|
||||
type="defaultSeo"
|
||||
data={{
|
||||
title: name,
|
||||
description,
|
||||
titleTemplate: `%s · ${name}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SHOP_QUERY = gql`
|
||||
query shopInfo {
|
||||
shop {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
30
packages/hydrogen/test/fixtures/demo-store-js/src/components/HeaderFallback.jsx
vendored
Normal file
30
packages/hydrogen/test/fixtures/demo-store-js/src/components/HeaderFallback.jsx
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
export function HeaderFallback({isHome}) {
|
||||
const styles = isHome
|
||||
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
|
||||
: 'bg-contrast/80 text-primary';
|
||||
return (
|
||||
<header
|
||||
role="banner"
|
||||
className={`${styles} flex h-nav items-center backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-8 px-12 py-8`}
|
||||
>
|
||||
<div className="flex space-x-4">
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
</div>
|
||||
<Box isHome={isHome} wide={true} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Box({wide, isHome}) {
|
||||
return (
|
||||
<div
|
||||
className={`h-6 rounded-sm ${wide ? 'w-32' : 'w-16'} ${
|
||||
isHome ? 'bg-primary/60' : 'bg-primary/20'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountActivateForm.client.jsx
vendored
Normal file
163
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountActivateForm.client.jsx
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from '@shopify/hydrogen/client';
|
||||
|
||||
export function AccountActivateForm({id, activationToken}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [passwordConfirmError, setPasswordConfirmError] = useState(null);
|
||||
|
||||
function passwordValidation(form) {
|
||||
setPasswordError(null);
|
||||
setPasswordConfirmError(null);
|
||||
|
||||
let hasError = false;
|
||||
|
||||
if (!form.password.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (!form.passwordConfirm.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please re-enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError('The two passwords entered did not match.');
|
||||
}
|
||||
|
||||
return hasError;
|
||||
}
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (passwordValidation(event.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await callActivateApi({
|
||||
id,
|
||||
activationToken,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setSubmitError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/account');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-4xl">Activate Account.</h1>
|
||||
<p className="mt-4">Create your password to activate your account.</p>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-primary/30">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary placeholder:text-primary/30 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-notice' : 'border-primary'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordConfirmError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
value={passwordConfirm}
|
||||
required
|
||||
minLength={8}
|
||||
onChange={(event) => {
|
||||
setPasswordConfirm(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordConfirmError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordConfirmError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="block w-full px-4 py-2 text-contrast uppercase bg-gray-900 focus:shadow-outline"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function callActivateApi({id, activationToken, password}) {
|
||||
try {
|
||||
const res = await fetch(`/account/activate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({id, activationToken, password}),
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
145
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountAddressBook.client.jsx
vendored
Normal file
145
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountAddressBook.client.jsx
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
import {useState, useMemo} from 'react';
|
||||
|
||||
import {Text, Button} from '~/components/elements';
|
||||
import {Modal} from '../index';
|
||||
import {AccountAddressEdit, AccountDeleteAddress} from '../index';
|
||||
|
||||
export function AccountAddressBook({addresses, defaultAddress}) {
|
||||
const [editingAddress, setEditingAddress] = useState(null);
|
||||
const [deletingAddress, setDeletingAddress] = useState(null);
|
||||
|
||||
const {fullDefaultAddress, addressesWithoutDefault} = useMemo(() => {
|
||||
const defaultAddressIndex = addresses.findIndex(
|
||||
(address) => address.id === defaultAddress,
|
||||
);
|
||||
return {
|
||||
addressesWithoutDefault: [
|
||||
...addresses.slice(0, defaultAddressIndex),
|
||||
...addresses.slice(defaultAddressIndex + 1, addresses.length),
|
||||
],
|
||||
fullDefaultAddress: addresses[defaultAddressIndex],
|
||||
};
|
||||
}, [addresses, defaultAddress]);
|
||||
|
||||
function close() {
|
||||
setEditingAddress(null);
|
||||
setDeletingAddress(null);
|
||||
}
|
||||
|
||||
function editAddress(address) {
|
||||
setEditingAddress(address);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{deletingAddress ? (
|
||||
<Modal close={close}>
|
||||
<AccountDeleteAddress addressId={deletingAddress} close={close} />
|
||||
</Modal>
|
||||
) : null}
|
||||
{editingAddress ? (
|
||||
<Modal close={close}>
|
||||
<AccountAddressEdit
|
||||
address={editingAddress}
|
||||
defaultAddress={fullDefaultAddress === editingAddress}
|
||||
close={close}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
|
||||
<h3 className="font-bold text-lead">Address Book</h3>
|
||||
<div>
|
||||
{!addresses?.length ? (
|
||||
<Text className="mb-1" width="narrow" as="p" size="copy">
|
||||
You haven't saved any addresses yet.
|
||||
</Text>
|
||||
) : null}
|
||||
<div className="w-48">
|
||||
<Button
|
||||
className="mt-2 text-sm w-full mb-6"
|
||||
onClick={() => {
|
||||
editAddress({
|
||||
/** empty address */
|
||||
});
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Add an Address
|
||||
</Button>
|
||||
</div>
|
||||
{addresses?.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{fullDefaultAddress ? (
|
||||
<Address
|
||||
address={fullDefaultAddress}
|
||||
defaultAddress
|
||||
setDeletingAddress={setDeletingAddress.bind(
|
||||
null,
|
||||
fullDefaultAddress.originalId,
|
||||
)}
|
||||
editAddress={editAddress}
|
||||
/>
|
||||
) : null}
|
||||
{addressesWithoutDefault.map((address) => (
|
||||
<Address
|
||||
key={address.id}
|
||||
address={address}
|
||||
setDeletingAddress={setDeletingAddress.bind(
|
||||
null,
|
||||
address.originalId,
|
||||
)}
|
||||
editAddress={editAddress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Address({address, defaultAddress, editAddress, setDeletingAddress}) {
|
||||
return (
|
||||
<div className="lg:p-8 p-6 border border-gray-200 rounded flex flex-col">
|
||||
{defaultAddress ? (
|
||||
<div className="mb-3 flex flex-row">
|
||||
<span className="px-3 py-1 text-xs font-medium rounded-full bg-primary/20 text-primary/50">
|
||||
Default
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="flex-1 flex-row">
|
||||
{address.firstName || address.lastName ? (
|
||||
<li>
|
||||
{(address.firstName && address.firstName + ' ') + address.lastName}
|
||||
</li>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{address.formatted ? (
|
||||
address.formatted.map((line) => <li key={line}>{line}</li>)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-row font-medium mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
editAddress(address);
|
||||
}}
|
||||
className="text-left underline text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={setDeletingAddress}
|
||||
className="text-left text-primary/50 ml-6 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountAddressEdit.client.jsx
vendored
Normal file
316
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountAddressEdit.client.jsx
vendored
Normal file
@@ -0,0 +1,316 @@
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useRenderServerComponents} from '~/lib/utils';
|
||||
|
||||
import {Button, Text} from '~/components';
|
||||
|
||||
export function AccountAddressEdit({address, defaultAddress, close}) {
|
||||
const isNewAddress = useMemo(() => !Object.keys(address).length, [address]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [address1, setAddress1] = useState(address?.address1 || '');
|
||||
const [address2, setAddress2] = useState(address?.address2 || '');
|
||||
const [firstName, setFirstName] = useState(address?.firstName || '');
|
||||
const [lastName, setLastName] = useState(address?.lastName || '');
|
||||
const [company, setCompany] = useState(address?.company || '');
|
||||
const [country, setCountry] = useState(address?.country || '');
|
||||
const [province, setProvince] = useState(address?.province || '');
|
||||
const [city, setCity] = useState(address?.city || '');
|
||||
const [zip, setZip] = useState(address?.zip || '');
|
||||
const [phone, setPhone] = useState(address?.phone || '');
|
||||
const [isDefaultAddress, setIsDefaultAddress] = useState(defaultAddress);
|
||||
|
||||
// Necessary for edits to show up on the main page
|
||||
const renderServerComponents = useRenderServerComponents();
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const response = await callUpdateAddressApi({
|
||||
id: address?.originalId,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
zip,
|
||||
phone,
|
||||
isDefaultAddress,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (response.error) {
|
||||
setSubmitError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
renderServerComponents();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="mt-4 mb-6" as="h3" size="lead">
|
||||
{isNewAddress ? 'Add address' : 'Edit address'}
|
||||
</Text>
|
||||
<div className="max-w-lg">
|
||||
<form noValidate onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-red-100 rounded">
|
||||
<p className="m-4 text-sm text-red-900">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="firstname"
|
||||
name="firstname"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="lastname"
|
||||
name="lastname"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
value={lastName}
|
||||
onChange={(event) => {
|
||||
setLastName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="company"
|
||||
name="company"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
placeholder="Company"
|
||||
aria-label="Company"
|
||||
value={company}
|
||||
onChange={(event) => {
|
||||
setCompany(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="street1"
|
||||
name="street1"
|
||||
type="text"
|
||||
autoComplete="address-line1"
|
||||
placeholder="Address line 1*"
|
||||
required
|
||||
aria-label="Address line 1"
|
||||
value={address1}
|
||||
onChange={(event) => {
|
||||
setAddress1(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="address2"
|
||||
name="address2"
|
||||
type="text"
|
||||
autoComplete="address-line2"
|
||||
placeholder="Addresss line 2"
|
||||
aria-label="Address line 2"
|
||||
value={address2}
|
||||
onChange={(event) => {
|
||||
setAddress2(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="city"
|
||||
name="city"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
placeholder="City"
|
||||
aria-label="City"
|
||||
value={city}
|
||||
onChange={(event) => {
|
||||
setCity(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="state"
|
||||
name="state"
|
||||
type="text"
|
||||
autoComplete="address-level1"
|
||||
placeholder="State / Province"
|
||||
required
|
||||
aria-label="State"
|
||||
value={province}
|
||||
onChange={(event) => {
|
||||
setProvince(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="zip"
|
||||
name="zip"
|
||||
type="text"
|
||||
autoComplete="postal-code"
|
||||
placeholder="Zip / Postal Code"
|
||||
required
|
||||
aria-label="Zip"
|
||||
value={zip}
|
||||
onChange={(event) => {
|
||||
setZip(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="country"
|
||||
name="country"
|
||||
type="text"
|
||||
autoComplete="country-name"
|
||||
placeholder="Country"
|
||||
required
|
||||
aria-label="Country"
|
||||
value={country}
|
||||
onChange={(event) => {
|
||||
setCountry(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Phone"
|
||||
aria-label="Phone"
|
||||
value={phone}
|
||||
onChange={(event) => {
|
||||
setPhone(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
name="defaultAddress"
|
||||
id="defaultAddress"
|
||||
checked={isDefaultAddress}
|
||||
className="border-gray-500 rounded-sm cursor-pointer border-1"
|
||||
onChange={() => setIsDefaultAddress(!isDefaultAddress)}
|
||||
/>
|
||||
<label
|
||||
className="inline-block ml-2 text-sm cursor-pointer"
|
||||
htmlFor="defaultAddress"
|
||||
>
|
||||
Set as default address
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
className="w-full rounded focus:shadow-outline"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className="w-full mt-2 rounded focus:shadow-outline"
|
||||
variant="secondary"
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callUpdateAddressApi({
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
phone,
|
||||
zip,
|
||||
isDefaultAddress,
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
id ? `/account/address/${encodeURIComponent(id)}` : '/account/address',
|
||||
{
|
||||
method: id ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
phone,
|
||||
zip,
|
||||
isDefaultAddress,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
error: 'Error saving address. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
163
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountCreateForm.client.jsx
vendored
Normal file
163
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountCreateForm.client.jsx
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate, Link} from '@shopify/hydrogen/client';
|
||||
|
||||
import {emailValidation, passwordValidation} from '~/lib/utils';
|
||||
|
||||
import {callLoginApi} from './AccountLoginForm.client';
|
||||
|
||||
export function AccountCreateForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setPasswordError(null);
|
||||
setSubmitError(null);
|
||||
|
||||
const newEmailError = emailValidation(event.currentTarget.email);
|
||||
if (newEmailError) {
|
||||
setEmailError(newEmailError);
|
||||
}
|
||||
|
||||
const newPasswordError = passwordValidation(event.currentTarget.password);
|
||||
if (newPasswordError) {
|
||||
setPasswordError(newPasswordError);
|
||||
}
|
||||
|
||||
if (newEmailError || newPasswordError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountCreateResponse = await callAccountCreateApi({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (accountCreateResponse.error) {
|
||||
setSubmitError(accountCreateResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// this can be avoided if customerCreate mutation returns customerAccessToken
|
||||
await callLoginApi({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
navigate('/account');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<h1 className="text-4xl">Create an Account.</h1>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
emailError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!emailError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{emailError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!passwordError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{passwordError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center mt-4">
|
||||
<p className="align-baseline text-sm">
|
||||
Already have an account?
|
||||
<Link className="inline underline" to="/account">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callAccountCreateApi({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({email, password, firstName, lastName}),
|
||||
});
|
||||
if (res.status === 200) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Text, Button} from '~/components/elements';
|
||||
import {useRenderServerComponents} from '~/lib/utils';
|
||||
|
||||
export function AccountDeleteAddress({addressId, close}) {
|
||||
// Necessary for edits to show up on the main page
|
||||
const renderServerComponents = useRenderServerComponents();
|
||||
|
||||
async function deleteAddress(id) {
|
||||
const response = await callDeleteAddressApi(id);
|
||||
if (response.error) {
|
||||
alert(response.error);
|
||||
return;
|
||||
}
|
||||
renderServerComponents();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="mb-4" as="h3" size="lead">
|
||||
Confirm removal
|
||||
</Text>
|
||||
<Text as="p">Are you sure you wish to remove this address?</Text>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
deleteAddress(addressId);
|
||||
}}
|
||||
variant="primary"
|
||||
width="full"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
className="text-sm mt-2"
|
||||
onClick={close}
|
||||
variant="secondary"
|
||||
width="full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callDeleteAddressApi(id) {
|
||||
try {
|
||||
const res = await fetch(`/account/address/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
error: 'Error removing address. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
56
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountDetails.client.jsx
vendored
Normal file
56
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountDetails.client.jsx
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
import {Seo} from '@shopify/hydrogen';
|
||||
import {useState} from 'react';
|
||||
import {Modal} from '../index';
|
||||
import {AccountDetailsEdit} from './AccountDetailsEdit.client';
|
||||
|
||||
export function AccountDetails({firstName, lastName, phone, email}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const close = () => setIsEditing(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<Modal close={close}>
|
||||
<Seo type="noindex" data={{title: 'Account details'}} />
|
||||
<AccountDetailsEdit
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
phone={phone}
|
||||
email={email}
|
||||
close={close}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
|
||||
<h3 className="font-bold text-lead">Account Details</h3>
|
||||
<div className="lg:p-8 p-6 border border-gray-200 rounded">
|
||||
<div className="flex">
|
||||
<h3 className="font-bold text-base flex-1">Profile & Security</h3>
|
||||
<button
|
||||
className="underline text-sm font-normal"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-primary/50">Name</div>
|
||||
<p className="mt-1">
|
||||
{firstName || lastName
|
||||
? (firstName ? firstName + ' ' : '') + lastName
|
||||
: 'Add name'}{' '}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 text-sm text-primary/50">Contact</div>
|
||||
<p className="mt-1">{phone ?? 'Add mobile'}</p>
|
||||
|
||||
<div className="mt-4 text-sm text-primary/50">Email address</div>
|
||||
<p className="mt-1">{email}</p>
|
||||
|
||||
<div className="mt-4 text-sm text-primary/50">Password</div>
|
||||
<p className="mt-1">**************</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
305
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountDetailsEdit.client.jsx
vendored
Normal file
305
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountDetailsEdit.client.jsx
vendored
Normal file
@@ -0,0 +1,305 @@
|
||||
import {useState} from 'react';
|
||||
|
||||
import {Text, Button} from '~/components';
|
||||
import {
|
||||
emailValidation,
|
||||
passwordValidation,
|
||||
useRenderServerComponents,
|
||||
} from '~/lib/utils';
|
||||
|
||||
export function AccountDetailsEdit({
|
||||
firstName: _firstName = '',
|
||||
lastName: _lastName = '',
|
||||
phone: _phone = '',
|
||||
email: _email = '',
|
||||
close,
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [firstName, setFirstName] = useState(_firstName);
|
||||
const [lastName, setLastName] = useState(_lastName);
|
||||
const [phone, setPhone] = useState(_phone);
|
||||
const [email, setEmail] = useState(_email);
|
||||
const [emailError, setEmailError] = useState(null);
|
||||
const [currentPasswordError, setCurrentPasswordError] = useState(null);
|
||||
const [newPasswordError, setNewPasswordError] = useState(null);
|
||||
const [newPassword2Error, setNewPassword2Error] = useState(null);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
|
||||
// Necessary for edits to show up on the main page
|
||||
const renderServerComponents = useRenderServerComponents();
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setCurrentPasswordError(null);
|
||||
setNewPasswordError(null);
|
||||
setNewPassword2Error(null);
|
||||
|
||||
const emailError = emailValidation(event.currentTarget.email);
|
||||
if (emailError) {
|
||||
setEmailError(emailError);
|
||||
}
|
||||
|
||||
let currentPasswordError, newPasswordError, newPassword2Error;
|
||||
|
||||
// Only validate the password fields if the current password has a value
|
||||
if (event.currentTarget.currentPassword.value) {
|
||||
currentPasswordError = passwordValidation(
|
||||
event.currentTarget.currentPassword,
|
||||
);
|
||||
if (currentPasswordError) {
|
||||
setCurrentPasswordError(currentPasswordError);
|
||||
}
|
||||
|
||||
newPasswordError = passwordValidation(event.currentTarget.newPassword);
|
||||
if (newPasswordError) {
|
||||
setNewPasswordError(newPasswordError);
|
||||
}
|
||||
|
||||
newPassword2Error =
|
||||
event.currentTarget.newPassword.value !==
|
||||
event.currentTarget.newPassword2.value
|
||||
? 'The two passwords entered did not match'
|
||||
: null;
|
||||
if (newPassword2Error) {
|
||||
setNewPassword2Error(newPassword2Error);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
emailError ||
|
||||
currentPasswordError ||
|
||||
newPasswordError ||
|
||||
newPassword2Error
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const accountUpdateResponse = await callAccountUpdateApi({
|
||||
email,
|
||||
newPassword: event.currentTarget.newPassword.value,
|
||||
currentPassword: event.currentTarget.currentPassword.value,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (accountUpdateResponse.error) {
|
||||
setSubmitError(accountUpdateResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
renderServerComponents();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="mt-4 mb-6" as="h3" size="lead">
|
||||
Update your profile
|
||||
</Text>
|
||||
<form noValidate onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-red-100 rounded">
|
||||
<p className="m-4 text-sm text-red-900">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="firstname"
|
||||
name="firstname"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="lastname"
|
||||
name="lastname"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
value={lastName}
|
||||
onChange={(event) => {
|
||||
setLastName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Mobile"
|
||||
aria-label="Mobile"
|
||||
value={phone}
|
||||
onChange={(event) => {
|
||||
setPhone(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline rounded ${
|
||||
emailError ? ' border-red-500' : 'border-gray-500'
|
||||
}`}
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${!emailError ? 'invisible' : ''}`}
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
</div>
|
||||
<Text className="mb-6 mt-6" as="h3" size="lead">
|
||||
Change your password
|
||||
</Text>
|
||||
<Password
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
passwordError={currentPasswordError}
|
||||
/>
|
||||
<Password
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
passwordError={newPasswordError}
|
||||
/>
|
||||
<Password
|
||||
name="newPassword2"
|
||||
label="Re-enter new password"
|
||||
passwordError={newPassword2Error}
|
||||
/>
|
||||
<Text
|
||||
size="fine"
|
||||
color="subtle"
|
||||
className={`mt-1 ${
|
||||
currentPasswordError || newPasswordError ? 'text-red-500' : ''
|
||||
}`}
|
||||
>
|
||||
Passwords must be at least 6 characters.
|
||||
</Text>
|
||||
{newPassword2Error ? <br /> : null}
|
||||
<Text
|
||||
size="fine"
|
||||
className={`mt-1 text-red-500 ${
|
||||
newPassword2Error ? '' : 'invisible'
|
||||
}`}
|
||||
>
|
||||
{newPassword2Error}
|
||||
</Text>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
className="text-sm mb-2"
|
||||
variant="primary"
|
||||
width="full"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="text-sm"
|
||||
variant="secondary"
|
||||
width="full"
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Password({name, passwordError, label}) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline rounded ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-500'
|
||||
}`}
|
||||
id={name}
|
||||
name={name}
|
||||
type="password"
|
||||
autoComplete={
|
||||
name === 'currentPassword' ? 'current-password' : undefined
|
||||
}
|
||||
placeholder={label}
|
||||
aria-label={label}
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callAccountUpdateApi({
|
||||
email,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
error: 'Error saving account. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
248
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountLoginForm.client.jsx
vendored
Normal file
248
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountLoginForm.client.jsx
vendored
Normal file
@@ -0,0 +1,248 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate, Link} from '@shopify/hydrogen/client';
|
||||
|
||||
export function AccountLoginForm({shopName}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [hasSubmitError, setHasSubmitError] = useState(false);
|
||||
const [showEmailField, setShowEmailField] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
|
||||
function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setHasSubmitError(false);
|
||||
setPasswordError(null);
|
||||
|
||||
if (showEmailField) {
|
||||
checkEmail(event);
|
||||
} else {
|
||||
checkPassword(event);
|
||||
}
|
||||
}
|
||||
|
||||
function checkEmail(event) {
|
||||
if (event.currentTarget.email.validity.valid) {
|
||||
setShowEmailField(false);
|
||||
} else {
|
||||
setEmailError('Please enter a valid email');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPassword(event) {
|
||||
const validity = event.currentTarget.password.validity;
|
||||
if (validity.valid) {
|
||||
const response = await callLoginApi({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setHasSubmitError(true);
|
||||
resetForm();
|
||||
} else {
|
||||
navigate('/account');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(
|
||||
validity.valueMissing
|
||||
? 'Please enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setShowEmailField(true);
|
||||
setEmail('');
|
||||
setEmailError(null);
|
||||
setPassword('');
|
||||
setPasswordError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<h1 className="text-4xl">Sign in.</h1>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{hasSubmitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">
|
||||
Sorry we did not recognize either your email or password. Please
|
||||
try to sign in again or create a new account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{showEmailField && (
|
||||
<EmailField
|
||||
shopName={shopName}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
emailError={emailError}
|
||||
/>
|
||||
)}
|
||||
{!showEmailField && (
|
||||
<ValidEmail email={email} resetForm={resetForm} />
|
||||
)}
|
||||
{!showEmailField && (
|
||||
<PasswordField
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
passwordError={passwordError}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callLoginApi({email, password}) {
|
||||
try {
|
||||
const res = await fetch(`/account/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({email, password}),
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function EmailField({email, setEmail, emailError, shopName}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
emailError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!emailError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{emailError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 rounded text-contrast py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center mt-8 border-t border-gray-300">
|
||||
<p className="align-baseline text-sm mt-6">
|
||||
New to {shopName}?
|
||||
<Link className="inline underline" to="/account/register">
|
||||
Create an account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidEmail({email, resetForm}) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p>{email}</p>
|
||||
<input
|
||||
className="hidden"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
readOnly
|
||||
></input>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="inline-block align-baseline text-sm underline"
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({password, setPassword, passwordError}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!passwordError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}> {passwordError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="flex-1"></div>
|
||||
<Link
|
||||
className="inline-block align-baseline text-sm text-primary/50"
|
||||
to="/account/recover"
|
||||
>
|
||||
Forgot password
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {Button, Text, OrderCard} from '~/components';
|
||||
|
||||
export function AccountOrderHistory({orders}) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
|
||||
<h2 className="font-bold text-lead">Order History</h2>
|
||||
{orders?.length ? <Orders orders={orders} /> : <EmptyOrders />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyOrders() {
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-1" size="fine" width="narrow" as="p">
|
||||
You haven't placed any orders yet.
|
||||
</Text>
|
||||
<div className="w-48">
|
||||
<Button className="text-sm mt-2 w-full" variant="secondary" to={'/'}>
|
||||
Start Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Orders({orders}) {
|
||||
return (
|
||||
<ul className="grid-flow-row grid gap-2 gap-y-6 md:gap-4 lg:gap-6 grid-cols-1 false sm:grid-cols-3">
|
||||
{orders.map((order) => (
|
||||
<OrderCard order={order} key={order.id} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from '@shopify/hydrogen/client';
|
||||
|
||||
export function AccountPasswordResetForm({id, resetToken}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [passwordConfirmError, setPasswordConfirmError] = useState(null);
|
||||
|
||||
function passwordValidation(form) {
|
||||
setPasswordError(null);
|
||||
setPasswordConfirmError(null);
|
||||
|
||||
let hasError = false;
|
||||
|
||||
if (!form.password.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (!form.passwordConfirm.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please re-enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError('The two password entered did not match.');
|
||||
}
|
||||
|
||||
return hasError;
|
||||
}
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (passwordValidation(event.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await callPasswordResetApi({
|
||||
id,
|
||||
resetToken,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setSubmitError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/account');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<h1 className="text-4xl">Reset Password.</h1>
|
||||
<p className="mt-4">Enter a new password for your account.</p>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordConfirmError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
value={passwordConfirm}
|
||||
required
|
||||
minLength={8}
|
||||
onChange={(event) => {
|
||||
setPasswordConfirm(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordConfirmError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordConfirmError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callPasswordResetApi({id, resetToken, password}) {
|
||||
try {
|
||||
const res = await fetch(`/account/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({id, resetToken, password}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
123
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountRecoverForm.client.jsx
vendored
Normal file
123
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/AccountRecoverForm.client.jsx
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
import {useState} from 'react';
|
||||
|
||||
import {emailValidation} from '~/lib/utils';
|
||||
|
||||
export function AccountRecoverForm() {
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState(null);
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setSubmitError(null);
|
||||
|
||||
const newEmailError = emailValidation(event.currentTarget.email);
|
||||
|
||||
if (newEmailError) {
|
||||
setEmailError(newEmailError);
|
||||
return;
|
||||
}
|
||||
|
||||
await callAccountRecoverApi({
|
||||
email,
|
||||
});
|
||||
|
||||
setEmail('');
|
||||
setSubmitSuccess(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
{submitSuccess ? (
|
||||
<>
|
||||
<h1 className="text-4xl">Request Sent.</h1>
|
||||
<p className="mt-4">
|
||||
If that email address is in our system, you will receive an email
|
||||
with instructions about how to reset your password in a few
|
||||
minutes.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-4xl">Forgot Password.</h1>
|
||||
<p className="mt-4">
|
||||
Enter the email address associated with your account to receive a
|
||||
link to reset your password.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 rounded appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
emailError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!emailError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{emailError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Request Reset Link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callAccountRecoverApi({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/recover`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({email, password, firstName, lastName}),
|
||||
});
|
||||
if (res.status === 200) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
11
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/index.js
vendored
Normal file
11
packages/hydrogen/test/fixtures/demo-store-js/src/components/account/index.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export {AccountActivateForm} from './AccountActivateForm.client';
|
||||
export {AccountAddressBook} from './AccountAddressBook.client';
|
||||
export {AccountAddressEdit} from './AccountAddressEdit.client';
|
||||
export {AccountCreateForm} from './AccountCreateForm.client';
|
||||
export {AccountDeleteAddress} from './AccountDeleteAddress.client';
|
||||
export {AccountDetails} from './AccountDetails.client';
|
||||
export {AccountDetailsEdit} from './AccountDetailsEdit.client';
|
||||
export {AccountLoginForm} from './AccountLoginForm.client';
|
||||
export {AccountOrderHistory} from './AccountOrderHistory.client';
|
||||
export {AccountPasswordResetForm} from './AccountPasswordResetForm.client';
|
||||
export {AccountRecoverForm} from './AccountRecoverForm.client';
|
||||
29
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/ArticleCard.jsx
vendored
Normal file
29
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/ArticleCard.jsx
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Image, Link} from '@shopify/hydrogen';
|
||||
|
||||
export function ArticleCard({blogHandle, article, loading}) {
|
||||
return (
|
||||
<li key={article.id}>
|
||||
<Link to={`/${blogHandle}/${article.handle}`}>
|
||||
{article.image && (
|
||||
<div className="card-image aspect-[3/2]">
|
||||
<Image
|
||||
alt={article.image.altText || article.title}
|
||||
className="object-cover w-full"
|
||||
data={article.image}
|
||||
height={400}
|
||||
loading={loading}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
width={600}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="mt-4 font-medium">{article.title}</h2>
|
||||
<span className="block mt-1">{article.publishedAt}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
29
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/CollectionCard.server.jsx
vendored
Normal file
29
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/CollectionCard.server.jsx
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Image, Link} from '@shopify/hydrogen';
|
||||
|
||||
import {Heading} from '~/components';
|
||||
|
||||
export function CollectionCard({collection, loading}) {
|
||||
return (
|
||||
<Link to={`/collections/${collection.handle}`} className="grid gap-4">
|
||||
<div className="card-image bg-primary/5 aspect-[3/2]">
|
||||
{collection?.image && (
|
||||
<Image
|
||||
alt={`Image of ${collection.title}`}
|
||||
data={collection.image}
|
||||
height={400}
|
||||
sizes="(max-width: 32em) 100vw, 33vw"
|
||||
width={600}
|
||||
widths={[400, 500, 600, 700, 800, 900]}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Heading as="h3" size="copy">
|
||||
{collection.title}
|
||||
</Heading>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
83
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/OrderCard.client.jsx
vendored
Normal file
83
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/OrderCard.client.jsx
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
import {Image, Link, flattenConnection} from '@shopify/hydrogen';
|
||||
|
||||
import {Heading, Text} from '~/components';
|
||||
import {statusMessage} from '~/lib/utils';
|
||||
|
||||
export function OrderCard({order}) {
|
||||
if (!order?.id) return null;
|
||||
const legacyOrderId = order.id.split('/').pop().split('?')[0];
|
||||
const lineItems = flattenConnection(order?.lineItems);
|
||||
|
||||
return (
|
||||
<li className="grid text-center border rounded">
|
||||
<Link
|
||||
className="grid items-center gap-4 p-4 md:gap-6 md:p-6 md:grid-cols-2"
|
||||
to={`/account/orders/${legacyOrderId}`}
|
||||
>
|
||||
{lineItems[0].variant?.image && (
|
||||
<div className="card-image aspect-square bg-primary/5">
|
||||
<Image
|
||||
width={168}
|
||||
height={168}
|
||||
widths={[168]}
|
||||
className="w-full fadeIn cover"
|
||||
alt={lineItems[0].variant?.image?.altText ?? 'Order image'}
|
||||
// @ts-expect-error Stock line item variant image type has `url` as optional
|
||||
data={lineItems[0].variant?.image}
|
||||
loaderOptions={{scale: 2, crop: 'center'}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex-col justify-center text-left ${
|
||||
!lineItems[0].variant?.image && 'md:col-span-2'
|
||||
}`}
|
||||
>
|
||||
<Heading as="h3" format size="copy">
|
||||
{lineItems.length > 1
|
||||
? `${lineItems[0].title} +${lineItems.length - 1} more`
|
||||
: lineItems[0].title}
|
||||
</Heading>
|
||||
<dl className="grid grid-gap-1">
|
||||
<dt className="sr-only">Order ID</dt>
|
||||
<dd>
|
||||
<Text size="fine" color="subtle">
|
||||
Order No. {order.orderNumber}
|
||||
</Text>
|
||||
</dd>
|
||||
<dt className="sr-only">Order Date</dt>
|
||||
<dd>
|
||||
<Text size="fine" color="subtle">
|
||||
{new Date(order.processedAt).toDateString()}
|
||||
</Text>
|
||||
</dd>
|
||||
<dt className="sr-only">Fulfillment Status</dt>
|
||||
<dd className="mt-2">
|
||||
<span
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full ${
|
||||
order.fulfillmentStatus === 'FULFILLED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-primary/5 text-primary/50'
|
||||
}`}
|
||||
>
|
||||
<Text size="fine">
|
||||
{statusMessage(order.fulfillmentStatus)}
|
||||
</Text>
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="self-end border-t">
|
||||
<Link
|
||||
className="block w-full p-2 text-center"
|
||||
to={`/account/orders/${legacyOrderId}`}
|
||||
>
|
||||
<Text color="subtle" className="ml-3">
|
||||
View Details
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
100
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/ProductCard.client.jsx
vendored
Normal file
100
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/ProductCard.client.jsx
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
flattenConnection,
|
||||
Image,
|
||||
Link,
|
||||
Money,
|
||||
useMoney,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Text} from '~/components';
|
||||
import {isDiscounted, isNewArrival} from '~/lib/utils';
|
||||
import {getProductPlaceholder} from '~/lib/placeholders';
|
||||
|
||||
export function ProductCard({product, label, className, loading, onClick}) {
|
||||
let cardLabel;
|
||||
|
||||
const cardData = product?.variants ? product : getProductPlaceholder();
|
||||
|
||||
const {
|
||||
image,
|
||||
priceV2: price,
|
||||
compareAtPriceV2: compareAtPrice,
|
||||
} = flattenConnection(cardData?.variants)[0] || {};
|
||||
|
||||
if (label) {
|
||||
cardLabel = label;
|
||||
} else if (isDiscounted(price, compareAtPrice)) {
|
||||
cardLabel = 'Sale';
|
||||
} else if (isNewArrival(product.publishedAt)) {
|
||||
cardLabel = 'New';
|
||||
}
|
||||
|
||||
const styles = clsx('grid gap-6', className);
|
||||
|
||||
return (
|
||||
<Link onClick={onClick} to={`/products/${product.handle}`}>
|
||||
<div className={styles}>
|
||||
<div className="card-image aspect-[4/5] bg-primary/5">
|
||||
<Text
|
||||
as="label"
|
||||
size="fine"
|
||||
className="absolute top-0 right-0 m-4 text-right text-notice"
|
||||
>
|
||||
{cardLabel}
|
||||
</Text>
|
||||
{image && (
|
||||
<Image
|
||||
className="aspect-[4/5] w-full object-cover fadeIn"
|
||||
widths={[320]}
|
||||
sizes="320px"
|
||||
loaderOptions={{
|
||||
crop: 'center',
|
||||
scale: 2,
|
||||
width: 320,
|
||||
height: 400,
|
||||
}}
|
||||
// @ts-ignore Stock type has `src` as optional
|
||||
data={image}
|
||||
alt={image.altText || `Picture of ${product.title}`}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Text
|
||||
className="w-full overflow-hidden whitespace-nowrap text-ellipsis "
|
||||
as="h3"
|
||||
>
|
||||
{product.title}
|
||||
</Text>
|
||||
<div className="flex gap-4">
|
||||
<Text className="flex gap-4">
|
||||
<Money withoutTrailingZeros data={price} />
|
||||
{isDiscounted(price, compareAtPrice) && (
|
||||
<CompareAtPrice
|
||||
className={'opacity-50'}
|
||||
data={compareAtPrice}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareAtPrice({data, className}) {
|
||||
const {currencyNarrowSymbol, withoutTrailingZerosAndCurrency} =
|
||||
useMoney(data);
|
||||
|
||||
const styles = clsx('strike', className);
|
||||
|
||||
return (
|
||||
<span className={styles}>
|
||||
{currencyNarrowSymbol}
|
||||
{withoutTrailingZerosAndCurrency}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
3
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/index.js
vendored
Normal file
3
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/index.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export {ArticleCard} from './ArticleCard';
|
||||
export {OrderCard} from './OrderCard.client';
|
||||
export {ProductCard} from './ProductCard.client';
|
||||
1
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/index.server.js
vendored
Normal file
1
packages/hydrogen/test/fixtures/demo-store-js/src/components/cards/index.server.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {CollectionCard} from './CollectionCard.server';
|
||||
94
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/CartDetails.client.jsx
vendored
Normal file
94
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/CartDetails.client.jsx
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
import {useRef} from 'react';
|
||||
import {useScroll} from 'react-use';
|
||||
import {
|
||||
useCart,
|
||||
CartLineProvider,
|
||||
CartShopPayButton,
|
||||
Money,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Button, Text, CartLineItem, CartEmpty} from '~/components';
|
||||
|
||||
export function CartDetails({layout, onClose}) {
|
||||
const {lines} = useCart();
|
||||
const scrollRef = useRef(null);
|
||||
const {y} = useScroll(scrollRef);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <CartEmpty onClose={onClose} layout={layout} />;
|
||||
}
|
||||
|
||||
const container = {
|
||||
drawer: 'grid grid-cols-1 h-screen-no-nav grid-rows-[1fr_auto]',
|
||||
page: 'pb-12 grid md:grid-cols-2 md:items-start gap-8 md:gap-8 lg:gap-12',
|
||||
};
|
||||
|
||||
const content = {
|
||||
drawer: 'px-6 pb-6 sm-max:pt-2 overflow-auto transition md:px-12',
|
||||
page: 'flex-grow md:translate-y-4',
|
||||
};
|
||||
|
||||
const summary = {
|
||||
drawer: 'grid gap-6 p-6 border-t md:px-12',
|
||||
page: 'sticky top-nav grid gap-6 p-4 md:px-6 md:translate-y-4 bg-primary/5 rounded w-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={container[layout]}>
|
||||
<section
|
||||
ref={scrollRef}
|
||||
aria-labelledby="cart-contents"
|
||||
className={`${content[layout]} ${y > 0 ? 'border-t' : ''}`}
|
||||
>
|
||||
<ul className="grid gap-6 md:gap-10">
|
||||
{lines.map((line) => {
|
||||
return (
|
||||
<CartLineProvider key={line.id} line={line}>
|
||||
<CartLineItem />
|
||||
</CartLineProvider>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
<section aria-labelledby="summary-heading" className={summary[layout]}>
|
||||
<h2 id="summary-heading" className="sr-only">
|
||||
Order summary
|
||||
</h2>
|
||||
<OrderSummary />
|
||||
<CartCheckoutActions />
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CartCheckoutActions() {
|
||||
const {checkoutUrl} = useCart();
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<Button to={checkoutUrl}>Continue to Checkout</Button>
|
||||
<CartShopPayButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderSummary() {
|
||||
const {cost} = useCart();
|
||||
return (
|
||||
<>
|
||||
<dl className="grid">
|
||||
<div className="flex items-center justify-between font-medium">
|
||||
<Text as="dt">Subtotal</Text>
|
||||
<Text as="dd">
|
||||
{cost?.subtotalAmount?.amount ? (
|
||||
<Money data={cost?.subtotalAmount} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/CartEmpty.client.jsx
vendored
Normal file
78
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/CartEmpty.client.jsx
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
import {useRef} from 'react';
|
||||
import {useScroll} from 'react-use';
|
||||
import {fetchSync} from '@shopify/hydrogen';
|
||||
import {Button, Text, ProductCard, Heading, Skeleton} from '~/components';
|
||||
import {Suspense} from 'react';
|
||||
|
||||
export function CartEmpty({onClose, layout = 'drawer'}) {
|
||||
const scrollRef = useRef(null);
|
||||
const {y} = useScroll(scrollRef);
|
||||
|
||||
const container = {
|
||||
drawer: `grid content-start gap-4 px-6 pb-8 transition overflow-y-scroll md:gap-12 md:px-12 h-screen-no-nav md:pb-12 ${
|
||||
y > 0 ? 'border-t' : ''
|
||||
}`,
|
||||
page: `grid pb-12 w-full md:items-start gap-4 md:gap-8 lg:gap-12`,
|
||||
};
|
||||
|
||||
const topProductsContainer = {
|
||||
drawer: '',
|
||||
page: 'md:grid-cols-4 sm:grid-col-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={container[layout]}>
|
||||
<section className="grid gap-6">
|
||||
<Text format>
|
||||
Looks like you haven’t added anything yet, let’s get you
|
||||
started!
|
||||
</Text>
|
||||
<div>
|
||||
<Button onClick={onClose}>Continue shopping</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-8 pt-4">
|
||||
<Heading format size="copy">
|
||||
Shop Best Sellers
|
||||
</Heading>
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-x-6 gap-y-8 ${topProductsContainer[layout]}`}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<TopProducts onClose={onClose} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopProducts({onClose}) {
|
||||
const products = fetchSync('/api/bestSellers').json();
|
||||
|
||||
if (products.length === 0) {
|
||||
return <Text format>No products found.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<ProductCard product={product} key={product.id} onClick={onClose} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<>
|
||||
{[...new Array(4)].map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="grid gap-2">
|
||||
<Skeleton className="aspect-[3/4]" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/CartLineItem.client.jsx
vendored
Normal file
96
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/CartLineItem.client.jsx
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
useCart,
|
||||
useCartLine,
|
||||
CartLineQuantityAdjustButton,
|
||||
CartLinePrice,
|
||||
CartLineQuantity,
|
||||
Image,
|
||||
Link,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Heading, IconRemove, Text} from '~/components';
|
||||
|
||||
export function CartLineItem() {
|
||||
const {linesRemove} = useCart();
|
||||
const {id: lineId, quantity, merchandise} = useCartLine();
|
||||
|
||||
return (
|
||||
<li key={lineId} className="flex gap-4">
|
||||
<div className="flex-shrink">
|
||||
<Image
|
||||
width={112}
|
||||
height={112}
|
||||
widths={[112]}
|
||||
data={merchandise.image}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
className="object-cover object-center w-24 h-24 border rounded md:w-28 md:h-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between flex-grow">
|
||||
<div className="grid gap-2">
|
||||
<Heading as="h3" size="copy">
|
||||
<Link to={`/products/${merchandise.product.handle}`}>
|
||||
{merchandise.product.title}
|
||||
</Link>
|
||||
</Heading>
|
||||
|
||||
<div className="grid pb-2">
|
||||
{(merchandise?.selectedOptions || []).map((option) => (
|
||||
<Text color="subtle" key={option.name}>
|
||||
{option.name}: {option.value}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex justify-start text-copy">
|
||||
<CartLineQuantityAdjust lineId={lineId} quantity={quantity} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => linesRemove([lineId])}
|
||||
className="flex items-center justify-center w-10 h-10 border rounded"
|
||||
>
|
||||
<span className="sr-only">Remove</span>
|
||||
<IconRemove aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Text>
|
||||
<CartLinePrice as="span" />
|
||||
</Text>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineQuantityAdjust({lineId, quantity}) {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={`quantity-${lineId}`} className="sr-only">
|
||||
Quantity, {quantity}
|
||||
</label>
|
||||
<div className="flex items-center border rounded">
|
||||
<CartLineQuantityAdjustButton
|
||||
adjust="decrease"
|
||||
aria-label="Decrease quantity"
|
||||
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:cursor-wait"
|
||||
>
|
||||
−
|
||||
</CartLineQuantityAdjustButton>
|
||||
<CartLineQuantity as="div" className="px-2 text-center" />
|
||||
<CartLineQuantityAdjustButton
|
||||
adjust="increase"
|
||||
aria-label="Increase quantity"
|
||||
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:cursor-wait"
|
||||
>
|
||||
+
|
||||
</CartLineQuantityAdjustButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/index.js
vendored
Normal file
3
packages/hydrogen/test/fixtures/demo-store-js/src/components/cart/index.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export {CartDetails} from './CartDetails.client';
|
||||
export {CartEmpty} from './CartEmpty.client';
|
||||
export {CartLineItem} from './CartLineItem.client';
|
||||
36
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Button.jsx
vendored
Normal file
36
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Button.jsx
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
import clsx from 'clsx';
|
||||
import {Link} from '@shopify/hydrogen';
|
||||
|
||||
import {missingClass} from '~/lib/utils';
|
||||
|
||||
export function Button({
|
||||
as = 'button',
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
width = 'auto',
|
||||
...props
|
||||
}) {
|
||||
const Component = props?.to ? Link : as;
|
||||
|
||||
const baseButtonClasses =
|
||||
'inline-block rounded font-medium text-center py-3 px-6';
|
||||
|
||||
const variants = {
|
||||
primary: `${baseButtonClasses} bg-primary text-contrast`,
|
||||
secondary: `${baseButtonClasses} border border-primary/10 bg-contrast text-primary`,
|
||||
inline: 'border-b border-primary/10 leading-none pb-1',
|
||||
};
|
||||
|
||||
const widths = {
|
||||
auto: 'w-auto',
|
||||
full: 'w-full',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
missingClass(className, 'bg-') && variants[variant],
|
||||
missingClass(className, 'w-') && widths[width],
|
||||
className,
|
||||
);
|
||||
|
||||
return <Component className={styles} {...props} />;
|
||||
}
|
||||
36
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Grid.jsx
vendored
Normal file
36
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Grid.jsx
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function Grid({
|
||||
as: Component = 'div',
|
||||
className,
|
||||
flow = 'row',
|
||||
gap = 'default',
|
||||
items = 4,
|
||||
layout = 'default',
|
||||
...props
|
||||
}) {
|
||||
const layouts = {
|
||||
default: `grid-cols-1 ${items === 2 && 'md:grid-cols-2'} ${
|
||||
items === 3 && 'sm:grid-cols-3'
|
||||
} ${items > 3 && 'md:grid-cols-3'} ${items >= 4 && 'lg:grid-cols-4'}`,
|
||||
products: `grid-cols-2 ${items >= 3 && 'md:grid-cols-3'} ${
|
||||
items >= 4 && 'lg:grid-cols-4'
|
||||
}`,
|
||||
auto: 'auto-cols-auto',
|
||||
blog: `grid-cols-2 pt-24`,
|
||||
};
|
||||
|
||||
const gaps = {
|
||||
default: 'grid gap-2 gap-y-6 md:gap-4 lg:gap-6',
|
||||
blog: 'grid gap-6',
|
||||
};
|
||||
|
||||
const flows = {
|
||||
row: 'grid-flow-row',
|
||||
col: 'grid-flow-col',
|
||||
};
|
||||
|
||||
const styles = clsx(flows[flow], gaps[gap], layouts[layout], className);
|
||||
|
||||
return <Component {...props} className={styles} />;
|
||||
}
|
||||
39
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Heading.jsx
vendored
Normal file
39
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Heading.jsx
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {missingClass, formatText} from '~/lib/utils';
|
||||
|
||||
export function Heading({
|
||||
as: Component = 'h2',
|
||||
children,
|
||||
className = '',
|
||||
format,
|
||||
size = 'heading',
|
||||
width = 'default',
|
||||
...props
|
||||
}) {
|
||||
const sizes = {
|
||||
display: 'font-bold text-display',
|
||||
heading: 'font-bold text-heading',
|
||||
lead: 'font-bold text-lead',
|
||||
copy: 'font-medium text-copy',
|
||||
};
|
||||
|
||||
const widths = {
|
||||
default: 'max-w-prose',
|
||||
narrow: 'max-w-prose-narrow',
|
||||
wide: 'max-w-prose-wide',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
|
||||
missingClass(className, 'max-w-') && widths[width],
|
||||
missingClass(className, 'font-') && sizes[size],
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<Component {...props} className={styles}>
|
||||
{format ? formatText(children) : children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
223
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Icon.jsx
vendored
Normal file
223
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Icon.jsx
vendored
Normal file
@@ -0,0 +1,223 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Icon({children, className, fill = 'currentColor', stroke, ...props}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
{...props}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
className={clsx('w-5 h-5', className)}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountIcon(props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Accounts</title>
|
||||
<circle cx="20" cy="10.5" r="4.5" strokeWidth="2" />
|
||||
<path
|
||||
d="M20 19C13.4375 19 9.5 20.2857 9.5 28H30.5C30.5 20.2857 26.5625 19 20 19Z"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMenu(props) {
|
||||
return (
|
||||
<Icon {...props} stroke={props.stroke || 'currentColor'}>
|
||||
<title>Menu</title>
|
||||
<line x1="3" y1="6.375" x2="17" y2="6.375" strokeWidth="1.25" />
|
||||
<line x1="3" y1="10.375" x2="17" y2="10.375" strokeWidth="1.25" />
|
||||
<line x1="3" y1="14.375" x2="17" y2="14.375" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClose(props) {
|
||||
return (
|
||||
<Icon {...props} stroke={props.stroke || 'currentColor'}>
|
||||
<title>Close</title>
|
||||
<line
|
||||
x1="4.44194"
|
||||
y1="4.30806"
|
||||
x2="15.7556"
|
||||
y2="15.6218"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<line
|
||||
y1="-0.625"
|
||||
x2="16"
|
||||
y2="-0.625"
|
||||
transform="matrix(-0.707107 0.707107 0.707107 0.707107 16 4.75)"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrow({direction = 'right'}) {
|
||||
let rotate;
|
||||
|
||||
switch (direction) {
|
||||
case 'right':
|
||||
rotate = 'rotate-0';
|
||||
break;
|
||||
case 'left':
|
||||
rotate = 'rotate-180';
|
||||
break;
|
||||
case 'up':
|
||||
rotate = '-rotate-90';
|
||||
break;
|
||||
case 'down':
|
||||
rotate = 'rotate-90';
|
||||
break;
|
||||
default:
|
||||
rotate = 'rotate-0';
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon className={`w-5 h-5 ${rotate}`}>
|
||||
<title>Arrow</title>
|
||||
<path d="M7 3L14 10L7 17" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCaret({
|
||||
direction = 'down',
|
||||
stroke = 'currentColor',
|
||||
...props
|
||||
}) {
|
||||
let rotate;
|
||||
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
rotate = 'rotate-0';
|
||||
break;
|
||||
case 'up':
|
||||
rotate = 'rotate-180';
|
||||
break;
|
||||
case 'left':
|
||||
rotate = '-rotate-90';
|
||||
break;
|
||||
case 'right':
|
||||
rotate = 'rotate-90';
|
||||
break;
|
||||
default:
|
||||
rotate = 'rotate-0';
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={`w-5 h-5 transition ${rotate}`}
|
||||
fill="transparent"
|
||||
stroke={stroke}
|
||||
>
|
||||
<title>Caret</title>
|
||||
<path d="M14 8L10 12L6 8" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSelect(props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Select</title>
|
||||
<path d="M7 8.5L10 6.5L13 8.5" strokeWidth="1.25" />
|
||||
<path d="M13 11.5L10 13.5L7 11.5" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBag(props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Bag</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.125 5a1.875 1.875 0 0 1 3.75 0v.375h-3.75V5Zm-1.25.375V5a3.125 3.125 0 1 1 6.25 0v.375h3.5V15A2.625 2.625 0 0 1 14 17.625H6A2.625 2.625 0 0 1 3.375 15V5.375h3.5ZM4.625 15V6.625h10.75V15c0 .76-.616 1.375-1.375 1.375H6c-.76 0-1.375-.616-1.375-1.375Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconAccount(props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Account</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.9998 12.625c-1.9141 0-3.6628.698-5.0435 1.8611C3.895 13.2935 3.25 11.7221 3.25 10c0-3.728 3.022-6.75 6.75-6.75 3.7279 0 6.75 3.022 6.75 6.75 0 1.7222-.645 3.2937-1.7065 4.4863-1.3807-1.1632-3.1295-1.8613-5.0437-1.8613ZM10 18c-2.3556 0-4.4734-1.0181-5.9374-2.6382C2.7806 13.9431 2 12.0627 2 10c0-4.4183 3.5817-8 8-8s8 3.5817 8 8-3.5817 8-8 8Zm0-12.5c-1.567 0-2.75 1.394-2.75 3s1.183 3 2.75 3 2.75-1.394 2.75-3-1.183-3-2.75-3Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHelp(props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Help</title>
|
||||
<path d="M3.375 10a6.625 6.625 0 1 1 13.25 0 6.625 6.625 0 0 1-13.25 0ZM10 2.125a7.875 7.875 0 1 0 0 15.75 7.875 7.875 0 0 0 0-15.75Zm.699 10.507H9.236V14h1.463v-1.368ZM7.675 7.576A3.256 3.256 0 0 0 7.5 8.67h1.245c0-.496.105-.89.316-1.182.218-.299.553-.448 1.005-.448a1 1 0 0 1 .327.065c.124.044.24.113.35.208.108.095.2.223.272.383.08.154.12.34.12.558a1.3 1.3 0 0 1-.076.471c-.044.131-.11.252-.197.361-.08.102-.174.197-.283.285-.102.087-.212.182-.328.284a3.157 3.157 0 0 0-.382.383c-.102.124-.19.27-.262.438a2.476 2.476 0 0 0-.164.591 6.333 6.333 0 0 0-.043.81h1.179c0-.263.021-.485.065-.668a1.65 1.65 0 0 1 .207-.47c.088-.139.19-.263.306-.372.117-.11.244-.223.382-.34l.35-.306c.116-.11.218-.23.305-.361.095-.139.168-.3.219-.482.058-.19.087-.412.087-.667 0-.35-.062-.664-.186-.942a1.881 1.881 0 0 0-.513-.689 2.07 2.07 0 0 0-.753-.427A2.721 2.721 0 0 0 10.12 6c-.4 0-.764.066-1.092.197a2.36 2.36 0 0 0-.83.536c-.225.234-.4.515-.523.843Z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch(props) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Search</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M13.3 8.52a4.77 4.77 0 1 1-9.55 0 4.77 4.77 0 0 1 9.55 0Zm-.98 4.68a6.02 6.02 0 1 1 .88-.88l4.3 4.3-.89.88-4.3-4.3Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({stroke = 'currentColor', ...props}) {
|
||||
return (
|
||||
<Icon {...props} fill="transparent" stroke={stroke}>
|
||||
<title>Check</title>
|
||||
<circle cx="10" cy="10" r="7.25" strokeWidth="1.25" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="m7.04 10.37 2.42 2.41 3.5-5.56"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRemove(props) {
|
||||
return (
|
||||
<Icon {...props} fill="transparent" stroke={props.stroke || 'currentColor'}>
|
||||
<title>Remove</title>
|
||||
<path
|
||||
d="M4 6H16"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M8.5 9V14" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.5 9V14" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path
|
||||
d="M5.5 6L6 17H14L14.5 6"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 6L8 5C8 4 8.75 3 10 3C11.25 3 12 4 12 5V6"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
14
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Input.jsx
vendored
Normal file
14
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Input.jsx
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function Input({className = '', type, variant, ...props}) {
|
||||
const variants = {
|
||||
search:
|
||||
'bg-transparent px-0 py-2 text-heading w-full focus:ring-0 border-x-0 border-t-0 transition border-b-2 border-primary/10 focus:border-primary/90',
|
||||
minisearch:
|
||||
'bg-transparent hidden md:inline-block text-left lg:text-right border-b transition border-transparent -mb-px border-x-0 border-t-0 appearance-none px-0 py-1 focus:ring-transparent placeholder:opacity-20 placeholder:text-inherit',
|
||||
};
|
||||
|
||||
const styles = clsx(variants[variant], className);
|
||||
|
||||
return <input type={type} {...props} className={styles} />;
|
||||
}
|
||||
16
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/LogoutButton.client.jsx
vendored
Normal file
16
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/LogoutButton.client.jsx
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export function LogoutButton(props) {
|
||||
const logout = () => {
|
||||
fetch('/account/logout', {method: 'POST'}).then(() => {
|
||||
if (typeof props?.onClick === 'function') {
|
||||
props.onClick();
|
||||
}
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="text-primary/50" {...props} onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
);
|
||||
}
|
||||
53
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Section.jsx
vendored
Normal file
53
packages/hydrogen/test/fixtures/demo-store-js/src/components/elements/Section.jsx
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {Heading} from '~/components';
|
||||
import {missingClass} from '~/lib/utils';
|
||||
|
||||
export function Section({
|
||||
as: Component = 'section',
|
||||
children,
|
||||
className,
|
||||
divider = 'none',
|
||||
display = 'grid',
|
||||
heading,
|
||||
padding = 'all',
|
||||
...props
|
||||
}) {
|
||||
const paddings = {
|
||||
x: 'px-6 md:px-8 lg:px-12',
|
||||
y: 'py-6 md:py-8 lg:py-12',
|
||||
swimlane: 'pt-4 md:pt-8 lg:pt-12 md:pb-4 lg:pb-8',
|
||||
all: 'p-6 md:p-8 lg:p-12',
|
||||
};
|
||||
|
||||
const dividers = {
|
||||
none: 'border-none',
|
||||
top: 'border-t border-primary/05',
|
||||
bottom: 'border-b border-primary/05',
|
||||
both: 'border-y border-primary/05',
|
||||
};
|
||||
|
||||
const displays = {
|
||||
flex: 'flex',
|
||||
grid: 'grid',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
'w-full gap-4 md:gap-8',
|
||||
displays[display],
|
||||
missingClass(className, '\\mp[xy]?-') && paddings[padding],
|
||||
dividers[divider],
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<Component {...props} className={styles}>
|
||||
{heading && (
|
||||
<Heading size="lead" className={padding === 'y' ? paddings['x'] : ''}>
|
||||
{heading}
|
||||
</Heading>
|
||||
)}
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user