Skip to content

Commit

Permalink
[gn] free recipe scans
Browse files Browse the repository at this point in the history
  • Loading branch information
a-type committed Nov 30, 2024
1 parent 3d9b02c commit 6ee1766
Show file tree
Hide file tree
Showing 13 changed files with 397 additions and 208 deletions.
10 changes: 3 additions & 7 deletions apps/gnocchi/web/src/components/promotional/DemoFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,13 @@ export function DemoFrame({ demo, className, ...rest }: DemoFrameProps) {
return (
<div
className={classNames(
'flex flex-col border-default rounded-lg overflow-hidden',
'flex flex-col border-default rounded-lg overflow-hidden mb-auto',
className,
)}
{...rest}
>
<video
autoPlay
loop
muted
controls={false}
src={`/videos/demos/${demo}.mp4`}
<img
src={`https://biscuits.club/images/gnocchi/${demo}.png`}
className="w-full h-auto object-cover"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function SubscriptionPromotionContent({}: SubscriptionPromotionContentPro
efficiently.
</P>
</div>
<DemoFrame demo="multiplayer-groceries" />
<DemoFrame demo="groceries-collaboration" />
<div>
<H2 className="mb-2">More recipe tools</H2>
<P className="mb-4">
Expand All @@ -32,7 +32,7 @@ export function SubscriptionPromotionContent({}: SubscriptionPromotionContentPro
stay on the same page.
</P>
</div>
<DemoFrame demo="multiplayer-cooking" />
<DemoFrame demo="recipe-collaboration" />
</div>
</>
);
Expand Down
48 changes: 38 additions & 10 deletions apps/gnocchi/web/src/components/recipes/editor/RecipeUrlField.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Icon } from '@/components/icons/Icon.jsx';
import { hooks } from '@/stores/groceries/index.js';
import { Button, LiveUpdateTextField } from '@a-type/ui';
import { useHasServerAccess } from '@biscuits/client';
import { Button, Dialog, LiveUpdateTextField } from '@a-type/ui';
import { LoginButton, useIsLoggedIn } from '@biscuits/client';
import { Recipe } from '@gnocchi.biscuits/verdant';
import { useState } from 'react';

Expand All @@ -11,10 +11,14 @@ export interface RecipeUrlFieldProps {

export function RecipeUrlField({ recipe }: RecipeUrlFieldProps) {
const { url } = hooks.useWatch(recipe);
const [initialUrl] = useState(() => recipe.get('url'));
const [scanning, setScanning] = useState(false);
const isSubscribed = useHasServerAccess();
const isLoggedIn = useIsLoggedIn();
const updateRecipeFromUrl = hooks.useUpdateRecipeFromUrl();

const hasUrlChanged = url !== initialUrl;
const showScan = hasUrlChanged || !url;

const scan = async () => {
if (url) {
try {
Expand All @@ -29,18 +33,42 @@ export function RecipeUrlField({ recipe }: RecipeUrlFieldProps) {
return (
<div className="flex gap-2 self-stretch w-full">
<LiveUpdateTextField
placeholder="Source URL"
placeholder="Paste a website"
value={url || ''}
onChange={(url) => recipe.set('url', url)}
type="url"
className="flex-1"
autoSelect
/>
{isSubscribed && url && (
<Button color="primary" onClick={scan} disabled={!url || scanning}>
<Icon name="scan" style={{ width: 15, height: 15 }} />
<span className="ml-2">Scan</span>
</Button>
)}
{showScan &&
(isLoggedIn ? (
<Button color="primary" onClick={scan} disabled={!url || scanning}>
<Icon name="scan" style={{ width: 15, height: 15 }} />
<span className="ml-2">Scan</span>
</Button>
) : (
<Dialog>
<Dialog.Trigger asChild>
<Button color="primary">
<Icon name="scan" style={{ width: 15, height: 15 }} />
<span className="ml-2">Scan</span>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Sign up to scan</Dialog.Title>
<Dialog.Description>
Get a free account to begin scanning web recipes. Free users get
5 recipes per month.
</Dialog.Description>
<Dialog.Actions>
<Dialog.Close>Cancel</Dialog.Close>
<LoginButton color="primary" asChild returnTo="/">
Get started
</LoginButton>
</Dialog.Actions>
</Dialog.Content>
</Dialog>
))}
</div>
);
}
33 changes: 27 additions & 6 deletions apps/gnocchi/web/src/stores/groceries/scanRecipe.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { graphql, isClientError, graphqlClient } from '@biscuits/graphql';
import { showSubscriptionPromotion } from '@biscuits/client';
import { detailedInstructionsToDoc, instructionsToDoc } from '@/lib/tiptap.js';
import { toast } from '@a-type/ui';
import { showSubscriptionPromotion } from '@biscuits/client';
import { BiscuitsError } from '@biscuits/error';
import { graphql, graphqlClient, isClientError } from '@biscuits/graphql';
import { lookupUnit, parseIngredient } from '@gnocchi.biscuits/conversion';
import { RecipeInit, Client } from '@gnocchi.biscuits/verdant';
import { toast } from '@a-type/ui';
import { Client, RecipeInit } from '@gnocchi.biscuits/verdant';

const recipeScanQuery = graphql(`
query RecipeScan($input: RecipeScanInput!) {
Expand Down Expand Up @@ -144,10 +144,31 @@ export async function getScannedRecipe(
err instanceof Error &&
isClientError(err) &&
err.graphQLErrors.some(
(e) => e.extensions?.code === BiscuitsError.Code.Forbidden,
(e) =>
e.extensions?.biscuitsCode === BiscuitsError.Code.Forbidden ||
e.extensions?.biscuitsCode === BiscuitsError.Code.UsageLimitReached,
)
) {
showSubscriptionPromotion();
if (
err.graphQLErrors.some(
(e) => e.extensions?.biscuitsCode === BiscuitsError.Code.Forbidden,
)
) {
showSubscriptionPromotion();
} else {
const resetsAt = err.graphQLErrors[0].extensions?.resetsAt as
| number
| null;
const message = resetsAt
? `You've used up your free scans for this month. You can wait until ${new Date(
resetsAt,
).toLocaleDateString()} to try again, or subscribe for unlimited scans.`
: `You've used up your free scans for this month. Subscribe for unlimited recipe scans.`;
showSubscriptionPromotion(
'Subscribe for unlimited recipe scans.',
message,
);
}
} else {
toast.error('Could not scan that recipe.');
}
Expand Down
25 changes: 19 additions & 6 deletions packages/client/src/components/SubscriptionPromotion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ import { graphql, useQuery } from '@biscuits/graphql';
import { ReactNode } from 'react';
import { proxy, useSnapshot } from 'valtio';
import { LoginButton } from './LoginButton.js';
import { Price } from './Price.js';

const subscriptionPromotionState = proxy({
status: 'closed' as 'closed' | 'open',
title: 'Upgrade for sync & more',
description: '',
});

export function showSubscriptionPromotion() {
export function showSubscriptionPromotion(
title: string = 'Upgrade for sync & more',
description: string = '',
) {
subscriptionPromotionState.status = 'open';
subscriptionPromotionState.title = title;
subscriptionPromotionState.description = description;
}

export interface SubscriptionPromotionProps {
Expand All @@ -30,17 +38,21 @@ const promotionProductQuery = graphql(`
id
price
currency
period
}
}
`);

export function SubscriptionPromotion({
children,
}: SubscriptionPromotionProps) {
const { status } = useSnapshot(subscriptionPromotionState);
const { status, title, description } = useSnapshot(
subscriptionPromotionState,
);
const { data } = useQuery(promotionProductQuery);
const price = data?.productInfo.price;
const currency = data?.productInfo.currency;
const period = data?.productInfo.period;

return (
<Dialog
Expand All @@ -53,15 +65,14 @@ export function SubscriptionPromotion({
>
<DialogContent width="lg">
<div className="flex flex-row items-start gap-2">
<DialogTitle className="flex-1">
Upgrade for sync &amp; more
</DialogTitle>
<DialogTitle className="flex-1">{title}</DialogTitle>
<DialogClose asChild>
<Button size="small" color="ghost">
<Icon name="x" />
</Button>
</DialogClose>
</div>
{description && <Dialog.Description>{description}</Dialog.Description>}
{children}
<DialogActions>
<div className="flex flex-col gap-2 items-center m-auto mt-1">
Expand All @@ -73,7 +84,9 @@ export function SubscriptionPromotion({
Join the club
</LoginButton>
<span className="text-xs">
Starting at {price} {currency} / month. 14 days free.
Starting at{' '}
<Price value={price} currency={currency} period={period} />. 14
days free.
</span>
</div>
</DialogActions>
Expand Down
67 changes: 35 additions & 32 deletions packages/db/src/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,51 @@
import * as v0001 from "./v0001.js";
import * as v0002 from "./v0002_add_member_limit.js";
import * as v0001 from './v0001.js';
import * as v0002 from './v0002_add_member_limit.js';

import * as v0003 from "./v0003_drop_unique_invite_constraint.js";
import * as v0003 from './v0003_drop_unique_invite_constraint.js';

import * as v0004 from "./v0004_push_notifications_and_changelog.js";
import * as v0004 from './v0004_push_notifications_and_changelog.js';

import * as v0005 from "./v0005_push_app_id.js";
import * as v0005 from './v0005_push_app_id.js';

import * as v0006 from "./v0006_foods.js";
import * as v0006 from './v0006_foods.js';

import * as v0007 from "./v0007_changelog_app_ids.js";
import * as v0007 from './v0007_changelog_app_ids.js';

import * as v0008 from "./v0008_more-indexes.js";
import * as v0008 from './v0008_more-indexes.js';

import * as v0009 from "./v0009_food_name_drop_id.js";
import * as v0009 from './v0009_food_name_drop_id.js';

import * as v0010 from "./v0010_add_user_preferences.js";
import * as v0010 from './v0010_add_user_preferences.js';

import * as v0011 from "./v0011_user_tos.js";
import * as v0011 from './v0011_user_tos.js';

import * as v0012 from "./v0012_user_notify_of_new_apps.js";
import * as v0012 from './v0012_user_notify_of_new_apps.js';

import * as v0013 from "./v0013_published_recipes.js";
import * as v0013 from './v0013_published_recipes.js';

import * as v0014 from "./v0014_published_wishlists.js";
import * as v0014 from './v0014_published_wishlists.js';

import * as v0015 from "./v0015_plan_allowed_apps.js";
import * as v0015 from './v0015_plan_allowed_apps.js';

import * as v0016 from "./v0016_wishlist_purchases.js";
import * as v0016 from './v0016_wishlist_purchases.js';

import * as v0017 from './v0017_usage_limits.js';
export default {
v0001,
v0002,
v0003,
v0004,
v0005,
v0006,
v0007,
v0008,
v0009,
v0010,
v0011,
v0012,
v0013,
v0014,
v0015,
v0016,
v0001,
v0002,
v0003,
v0004,
v0005,
v0006,
v0007,
v0008,
v0009,
v0010,
v0011,
v0012,
v0013,
v0014,
v0015,
v0016,
v0017,
};
27 changes: 27 additions & 0 deletions packages/db/src/migrations/v0017_usage_limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Kysely, sql } from 'kysely';

// wishlist purchases

export async function up(db: Kysely<any>) {
// so that the dev server doesn't immediately apply a no-op.
await db.schema
.createTable('UserUsageLimit')
.addColumn('userId', 'text', (b) =>
b.notNull().references('User.id').onDelete('cascade'),
)
.addColumn('limitType', 'text', (b) => b.notNull())
.addColumn('uses', 'integer', (b) => b.notNull().defaultTo(0))
.addColumn('resetsAt', 'datetime', (b) => b.notNull())
.addColumn('createdAt', 'datetime', (b) =>
b.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn('updatedAt', 'datetime', (b) =>
b.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addPrimaryKeyConstraint('UserUsageLimit_pk', ['userId', 'limitType'])
.execute();
}

export async function down(db: Kysely<any>) {
await db.schema.dropTable('UserUsageLimit').execute();
}
16 changes: 16 additions & 0 deletions packages/db/src/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface Database {
VerificationCode: VerificationCodeTable;
PushSubscription: PushSubscriptionTable;
ChangelogItem: ChangelogItemTable;
UserUsageLimit: UserUsageLimitTable;

// app-specific data
Food: FoodTable;
FoodName: FoodNameTable;
FoodCategoryAssignment: FoodCategoryAssignmentTable;
Expand Down Expand Up @@ -315,3 +318,16 @@ export interface WishlistIdeaRequestTable {
export type WishlistIdeaRequest = Selectable<WishlistIdeaRequestTable>;
export type NewWishlistIdeaRequest = Insertable<WishlistIdeaRequestTable>;
export type WishlistIdeaRequestUpdate = Updateable<WishlistIdeaRequestTable>;

export interface UserUsageLimitTable {
userId: string;
limitType: string;
createdAt: ColumnType<Date, Date | undefined, never>;
updatedAt: ColumnType<Date, Date | undefined, Date | undefined>;
uses: number;
resetsAt: ColumnType<Date, Date | undefined, Date | undefined>;
}

export type UsageLimit = Selectable<UserUsageLimitTable>;
export type NewUsageLimit = Insertable<UserUsageLimitTable>;
export type UsageLimitUpdate = Updateable<UserUsageLimitTable>;
8 changes: 7 additions & 1 deletion packages/error/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum BiscuitsErrorCode {
NotFound = 4040,
Conflict = 4090,
RateLimited = 4290,
UsageLimitReached = 4291,
Unexpected = 5000,
}

Expand Down Expand Up @@ -49,7 +50,12 @@ export class BiscuitsError extends Error {
public message: string;
public readonly isBiscuitsError = true;

constructor(code: BiscuitsErrorCode, message?: string, cause?: unknown) {
constructor(
code: BiscuitsErrorCode,
message?: string,
cause?: unknown,
public readonly extraData?: Record<string, any>,
) {
super(message, {
cause,
});
Expand Down
Loading

0 comments on commit 6ee1766

Please sign in to comment.